DarkRiDDeR13 мин

SSR/CSR mismatch: как не выдать второй fetch за исправление

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

Неоднозначный случай выглядит так: HTML уже показывает запись, но после hydrate код запускает client fetch. Если второй ответ меняет интерфейс, легко объявить это «нормальным обновлением». Проблема в том, что один и тот же симптом покрывает разные причины: snapshot не дошёл, version не совпала, initial state проигнорирован или новый запрос действительно был осознанным действием пользователя. Цена поспешного вывода — закрыть mismatch свежим ответом и потерять цепочку, которую нужно было расследовать.

Для разбора не нужен выдуманный production incident и не нужна красивая browser trace. Сначала достаточно собрать четыре локальных факта: откуда server взял source, что попало в serialized snapshot, какую версию несёт markup и кто создал client fetch. Затем нужно отделить documentation fact от project decision. React 17 требует согласованного server/client content, но не назначает вашу схему version и не решает, когда именно бизнес-код имеет право обновить запись.

Три причины одного повторного запроса

Первая причина — отсутствующий initial state. Bootstrap получает HTML, но его store начинается пустым, поэтому hook воспринимает страницу как новую и выполняет обычную загрузку. Вторая — mismatch contract: HTML и snapshot появились из разных source version или разного контекста. Третья — явное обновление: пользователь выбрал фильтр, нажал refresh или совершил другое действие, которое по контракту создаёт новую загрузку. Снаружи все три случая похожи на «двойной fetch», но исправления у них разные.

Диагностика повторного client fetch без предположения о причине
НаблюдениеГипотезаМинимальное доказательствоБезопасное следующее действие
snapshot отсутствует у bootstrapinitial state не переданнет serialized version рядом с HTMLпередать один named snapshot и повторить локальную проверку
markup и payload называют разные версииmismatch contractсохранить обе строки до mutationостановить неявный переход, выбрать отдельный recovery path
версии совпадают, но fetch естьhook игнорирует initial stateпоказать owner и условие запуска fetchпропустить request при accepted snapshot
fetch следует после пользовательского действияявное обновлениезафиксировать действие и новый inputоставить fetch, но не называть его hydration fallback

Учебная fixture показывает порядок, а не браузер

В пакете есть детерминированная in-memory fixture. Она строит один server envelope с entry-r7, затем два плана: matching hydrate и mismatch с ожидаемой версией entry-r8. Никакой реальный запрос не отправляется. JSON нужен только для имитации сериализованного payload, а markupVersion — учебный marker. Это важная граница: fixture не читает DOM и не доказывает, как конкретный React component будет patch markup.

import { runSsrCsrFixture } from "./upgrade-2022-01.mjs";

const fixture = runSsrCsrFixture();
const mismatch = fixture.mismatchHydration;

console.log(mismatch.outcome);
// mismatch-recorded-before-mutation
console.log(mismatch.diagnostic);
// expected version, serialized version and an explicit recovery requirement

if (mismatch.mutation.markup !== "not-started") {
  throw new Error("a mismatch was allowed to mutate the teaching model");
}
if (mismatch.clientFetch.performed) {
  throw new Error("a mismatch was hidden by an automatic client fetch");
}

В mismatch-пути диагностическая запись содержит expected и serialized version. Её outcome прямо называется mismatch-recorded-before-mutation. И markup mutation, и client state mutation остаются not-started; client fetch тоже не выполняется. Такой результат не равен готовому UX. Он только запрещает скрыть неоднозначность внутри общего loading state. После записи фактов команда всё ещё должна выбрать проектный путь восстановления и проверить его отдельно.

Диагностическая схема: повторный client fetch ветвится на отсутствие initial snapshot, совпавшую версию с игнорированным snapshot, mismatch версий и явное пользовательское обновление. Для mismatch показана остановка до mutation, сохранение двух версий и выбор отдельного recovery path.
Диаграмма не выдаёт все повторные запросы за ошибку. Она требует прежде назвать trigger, owner и version, а затем выбрать действие для конкретной ветки.

Что записать до изменения кода

Начните с карточки одного document response. В ней нужны route input, identity контекста, source version, markup marker, serialized version, имя bootstrap-функции и условие client fetch. Не подставляйте в карточку выдуманный timestamp, LCP или сетевой waterfall: если их не собирали, они не помогают отличить причины. Достаточно фактов, которые можно получить из собственного server response и initial-state boundary.

Затем проверьте порядок. Сравнение version обязано происходить раньше действия, которое создаёт новое состояние. Если current code делает наоборот — сначала mount запускает fetch, потом effect читает snapshot — речь не о тонком cache tuning. Нарушена граница: client уже получил право изменить state до того, как понял, что сервер передал. Исправление должно перенести owner check выше, а не только добавить условие на второй ответ.

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

  1. Симптом. Первый HTML есть, но сразу после hydrate начинается client fetch или содержимое меняется без действия пользователя.
  2. Причина. Возможны как минимум четыре ветки из таблицы; по одному network name нельзя выбрать ни одну из них.
  3. Проверка. Сохраните route input, source version, markup marker, serialized version и trigger fetch. Если marker отсутствует, это отдельный дефект контракта, а не доказательство «устаревших данных».
  4. Проверка порядка. Сравните версию до создания client state mutation. Для учебного mismatch fixture возвращает not-started и не прячет его новым fetch.
  5. Действие для отсутствующего snapshot. Передайте initial state явно и поставьте guard на повторный запрос при accepted snapshot.
  6. Действие для mismatch. Оставьте diagnostic visible, назовите recovery owner и покрыть выбранный путь отдельным test. Не называйте автоматическое обновление «исправлением», пока причина расхождения не определена.
  7. Действие для явного refresh. Свяжите fetch с пользовательским trigger и новым input; тогда он не смешивается с hydrate.

Проверка самой модели

Команда node web/scripts/upgrade-2022-01.mjs --verify-fixture проверяет двенадцать assertions. Среди них есть equal-version путь без второго fetch, запись mismatch до mutation, разделение phase/action для source, serialized snapshot, hydrate и client fetch, а также запрет выдавать схему за реализацию React или Next.js. Проверка не обращается к сети и не читает файлы. Она полезна как регрессия смысла: после рефакторинга labels и порядок переходов нельзя поменять молча.

Эта проверка не заменяет browser test. Реальная страница может иметь HTML mismatch из-за locale, timezone, cookie, permissions, nondeterministic data, feature flag или разного response cache. Для каждого такого input нужно решить: он является частью initial contract, даёт отдельную route variant или требует client-only rendering после согласованного первого прохода. Начните с одного фактора, который меняет visible output. Так диагностика остаётся конкретной, а не превращается в список модных причин.

Когда второй fetch действительно уместен

Повторная загрузка допустима, когда есть новый named trigger и понятный owner: пользователь поменял фильтр, истёк документированный freshness window, подписка принесла новую ревизию или UI вошёл в режим, которого не было в SSR. Даже тогда initial snapshot остаётся историей первого ответа, а client fetch — новой операцией с отдельным version. В диагностике это полезнее, чем общий флаг loading: можно увидеть, какой именно переход изменил state.

Не стоит лечить случаи без таблицы версий отключением SSR, случайным setTimeout или suppress warning. Такие меры могут убрать видимый переход, но не говорят, какой source сформировал initial HTML. Сначала зафиксируйте contract, потом выберите минимальный обратимый repair. Если в следующем случае окажется, что mismatch создаёт permission layer, у команды уже будет место, куда добавить эту ось, и test, который не даёт скрыть разрыв новым запросом.

Историческая граница

Диагностическая статья ограничена знаниями января 2022 года. Official documentation snapshots от 23 декабря 2021-го описывают ReactDOM.hydrate, ReactDOMServer.renderToString и ожидание одинакового rendered content; release React 17.0.2 опубликован в марте 2021-го. Версионная fixture, её actions и recovery policy — учебная и проектная модель. Поздние React/Next API, production метрики и результаты инцидентов здесь не заявляются.

Проверяемые источники

  • 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.