Проблема начинается не с самого SSR, а с ложного равенства: «данные на сервере», «данные в HTML» и «данные в клиентском состоянии» называют одним cache. Из-за этого client code не отличает уже подтверждённый snapshot от пустого состояния и запускает второй запрос. Цена — две конкурирующие версии первого экрана: одна пришла вместе с документом, другая может прийти позже и незаметно заменить её.
Причинная модель должна разделить не библиотеки, а переходы данных. Source отвечает за актуальную запись на момент server request. Serialized snapshot переносит результат этого чтения к документу. Hydrate решает, можно ли принять этот результат как initial state. Client fetch создаёт следующую версию только после явного правила. Пока эти четыре роли не разведены, оптимизация превращается в угадывание порядка effect, а mismatch маскируется новым ответом.
Один запрос создаёт два артефакта
Server renderer возвращает HTML. Для клиентского кода этого недостаточно: он видит элементы, но не знает, какой source сформировал их и какими входами управлялся. Поэтому рядом нужен сериализованный payload с версией. Это не дублирование бизнес-данных ради удобства. HTML — представление для первого показа, snapshot — переносимое начальное состояние для следующей фазы. Их общая version — точка, на которой можно проверить, что они говорят об одном чтении.
В fixture объект serverEnvelope хранит source, markupVersion и строку serializedSnapshot. Первые два значения берутся из entry-r7, а JSON содержит schema, version и payload. Такая форма намеренно скучна. Она показывает, что version должна быть рядом с данными, а не жить в отдельном глобальном флаге, который client code может прочитать уже после того, как начал запрашивать новое состояние.
import { runSsrCsrFixture } from "./upgrade-2022-01.mjs";
const fixture = runSsrCsrFixture();
const snapshot = JSON.parse(fixture.serverEnvelope.serializedSnapshot);
console.log(fixture.serverEnvelope.source.version);
console.log(snapshot.version);
console.log(fixture.matchingHydration.steps.map((step) => step.phase));
// ["ssr/source", "serialized-snapshot", "hydrate", "client-fetch"]
if (!fixture.assertions.serializedVersionEqualsServerSource) {
throw new Error("source and serialized snapshot lost one version boundary");
}
Владелец состояния меняется по фазам
После SSR source больше не должен считаться владельцем initial view: серверный request завершён. Пока hydrate не подтвердил snapshot, браузер не должен считать владельцем ни client cache, ни произвольный hook. При совпадении owner становится serialized-snapshot. Он не становится владельцем всей будущей истории записи; он только объясняет, откуда взялось начальное состояние этой страницы. Дальнейшее обновление — новый переход с отдельными условиями.
| Сущность | Создаётся где | Кто читает | Время жизни | Неверное упрощение |
|---|---|---|---|---|
| source | server request | server renderer | одно чтение на стороне сервера | считать его доступным client code |
| HTML | server renderer | пользователь и hydrate | до следующего browser update | считать HTML достаточным initial state |
| serialized snapshot | server response | hydrate | до принятия initial state | считать его общим cache приложения |
| client state | после принятия snapshot | интерактивный UI | по правилам конкретного проекта | создавать его пустым независимо от snapshot |
| client fetch | после явного условия | browser transition | новый запрос | запускать автоматически при каждом hydrate |
Почему mismatch нельзя исправлять молча
Рассмотрим учебный разрыв: markup ожидает entry-r8, а snapshot содержит entry-r7. В fixture planHydration сначала создаёт diagnostic с обеими версиями. Только после этого она возвращает mutation: not-started и clientFetch.performed: false. Такой порядок не утверждает, что React проверяет версии именно так. Он не даёт проектному коду спрятать смысл ошибки за незаметным reload.
Соблазнительный обход — сразу запрашивать source повторно. Он может нарисовать свежие данные, но стирает важный вопрос: почему документ и payload не совпали? Причина может быть в кешировании документа, неправильном ключе сериализации, подстановке пользователя, locale или ином owner. Автоматический fetch не делает эти различия безопаснее. Он только заменяет диагностируемое расхождение вторым состоянием, которое нельзя связать с первым HTML.
Маршрут: симптом → причина → проверка → действие
- Симптом. Devtools показывает повторный запрос после hydrate, либо UI меняется, хотя пользователь ещё ничего не сделал.
- Причина. Initial state создаётся независимо от snapshot, либо версия HTML и payload собирается из разных чтений source.
- Проверка. На одном документе выпишите source version, markup marker, serialized version и момент первого client fetch. Если хотя бы одно значение нельзя назвать, контракта ещё нет.
- Проверка модели. Запустите
node web/scripts/upgrade-2022-01.mjs --verify-fixture. В ней same-version путь обязан пропустить fetch, а mismatch — остаться видимым до mutation. - Действие. Сделайте snapshot единственным входом для initial state при совпадении. Для mismatch создайте явно названный recovery path и отдельный test, а не fallback в общий hook.
- Результат. У каждого значения появляется owner и время жизни; второй fetch становится осознанным переходом, а не побочным эффектом mount.
Граница модели и реализации
Документация React исторического периода говорит о существующей разметке, присоединении обработчиков и требовании идентичного rendered content. Она не описывает JSON shape из этой статьи, не знает поля entry-r7 и не выбирает fetch policy. Fixture не импортирует React, Next.js или DOM API; её action labels — учебные названия, а не trace framework runtime. Поэтому она пригодна для проверки причинной модели, но не для проверки конкретного renderer.
Project decision начинается там, где появляются реальные inputs: идентичность пользователя, cookie, locale, feature flags, route params, permission set, cache-control и время формирования документа. Не нужно складывать их в одну длинную строку version без объяснения. Лучше определить, какие из них действительно меняют HTML, кто формирует marker и где client bootstrap обязан сравнить его со snapshot. Этот контракт сложнее, чем один boolean, зато он не прячет ответственного за рассинхронизацию.
Цена двух крайностей
Первый крайний вариант — всегда trust snapshot. Он опасен, если version не привязана к реальному source или контексту рендера: пользователь может получить HTML и state из разных условий. Второй — всегда refetch on mount. Он тратит дополнительный переход, создаёт race между snapshot и новым ответом и лишает SSR смысла как источника первого state. Между ними находится проверяемое правило: trust только snapshot с явно совпавшим contract, а mismatches оставлять наблюдаемыми и разбирать до повторной загрузки.
Это правило не гарантирует отсутствие всех hydration warning и не заменяет тесты markup. Оно даёт понятную границу данных. Когда следующий bug касается разного locale или права доступа, его можно добавить как новую ось contract, а не объяснять задним числом тем, что «SSR иногда ведёт себя иначе». Так развивается система: от одной версии учебной записи к явно описанным входам, проверке и обратимому решению.
Историческая граница
Статья сознательно остаётся в январе 2022 года. Факт о ReactDOM.hydrate и ReactDOMServer.renderToString взят из immutable official documentation snapshot от 23 декабря 2021-го; release record React 17.0.2 датирован мартом 2021-го. Слова hydrateRoot, App Router, React Server Components и правила более поздних версий здесь не используются. Версионный protocol — не исторический API React, а проектная модель, помеченная отдельно.
Проверяемые источники
- 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.