DarkRiDDeR13 мин

Граница SSR и CSR: source, snapshot и hydrate не один cache

FrontendПроизводительность

Проблема начинается не с самого 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. Он не становится владельцем всей будущей истории записи; он только объясняет, откуда взялось начальное состояние этой страницы. Дальнейшее обновление — новый переход с отдельными условиями.

Причинная модель: что меняется, а что только переносится
СущностьСоздаётся гдеКто читаетВремя жизниНеверное упрощение
sourceserver requestserver rendererодно чтение на стороне серверасчитать его доступным client code
HTMLserver rendererпользователь и hydrateдо следующего browser updateсчитать HTML достаточным initial state
serialized snapshotserver responsehydrateдо принятия initial stateсчитать его общим cache приложения
client stateпосле принятия snapshotинтерактивный UIпо правилам конкретного проектасоздавать его пустым независимо от snapshot
client fetchпосле явного условияbrowser transitionновый запросзапускать автоматически при каждом hydrate
Схема владения: server request владеет source entry-r7, server response передаёт HTML и serialized snapshot, hydrate сравнивает marker с snapshot. При совпадении initial state принадлежит snapshot; client fetch остаётся отдельной поздней веткой.
Стрелки показывают передачу данных, а не общий cache. Перечёркнутый путь «source сразу в client state» указывает на потерянную границу времени и владельца.

Почему mismatch нельзя исправлять молча

Рассмотрим учебный разрыв: markup ожидает entry-r8, а snapshot содержит entry-r7. В fixture planHydration сначала создаёт diagnostic с обеими версиями. Только после этого она возвращает mutation: not-started и clientFetch.performed: false. Такой порядок не утверждает, что React проверяет версии именно так. Он не даёт проектному коду спрятать смысл ошибки за незаметным reload.

Соблазнительный обход — сразу запрашивать source повторно. Он может нарисовать свежие данные, но стирает важный вопрос: почему документ и payload не совпали? Причина может быть в кешировании документа, неправильном ключе сериализации, подстановке пользователя, locale или ином owner. Автоматический fetch не делает эти различия безопаснее. Он только заменяет диагностируемое расхождение вторым состоянием, которое нельзя связать с первым HTML.

Маршрут: симптом → причина → проверка → действие

  1. Симптом. Devtools показывает повторный запрос после hydrate, либо UI меняется, хотя пользователь ещё ничего не сделал.
  2. Причина. Initial state создаётся независимо от snapshot, либо версия HTML и payload собирается из разных чтений source.
  3. Проверка. На одном документе выпишите source version, markup marker, serialized version и момент первого client fetch. Если хотя бы одно значение нельзя назвать, контракта ещё нет.
  4. Проверка модели. Запустите node web/scripts/upgrade-2022-01.mjs --verify-fixture. В ней same-version путь обязан пропустить fetch, а mismatch — остаться видимым до mutation.
  5. Действие. Сделайте snapshot единственным входом для initial state при совпадении. Для mismatch создайте явно названный recovery path и отдельный test, а не fallback в общий hook.
  6. Результат. У каждого значения появляется 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.