Неоднозначный случай выглядит так: 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», но исправления у них разные.
| Наблюдение | Гипотеза | Минимальное доказательство | Безопасное следующее действие |
|---|---|---|---|
| snapshot отсутствует у bootstrap | initial 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. После записи фактов команда всё ещё должна выбрать проектный путь восстановления и проверить его отдельно.
Что записать до изменения кода
Начните с карточки одного 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 выше, а не только добавить условие на второй ответ.
Маршрут: симптом → причина → проверка → действие
- Симптом. Первый HTML есть, но сразу после hydrate начинается client fetch или содержимое меняется без действия пользователя.
- Причина. Возможны как минимум четыре ветки из таблицы; по одному network name нельзя выбрать ни одну из них.
- Проверка. Сохраните route input, source version, markup marker, serialized version и trigger fetch. Если marker отсутствует, это отдельный дефект контракта, а не доказательство «устаревших данных».
- Проверка порядка. Сравните версию до создания client state mutation. Для учебного mismatch fixture возвращает
not-startedи не прячет его новым fetch. - Действие для отсутствующего snapshot. Передайте initial state явно и поставьте guard на повторный запрос при accepted snapshot.
- Действие для mismatch. Оставьте diagnostic visible, назовите recovery owner и покрыть выбранный путь отдельным test. Не называйте автоматическое обновление «исправлением», пока причина расхождения не определена.
- Действие для явного 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.