{ "index": 214, "slug": "editorial-2022-01-field-ssr-csr", "title": "SSR и CSR: как диагностировать повторный fetch после первого экрана", "excerpt": "Первый экран уже пришёл с SSR, но после hydrate браузер повторяет fetch. Разбираем, как отличить отсутствие snapshot, рассинхрон версий, ошибку владельца состояния и осознанное обновление.", "contentHtml": "

Проблема заметна в браузере: сервер уже отдал список, пользователь видит первый экран, а сразу после hydrate (подключения клиентского кода к готовой разметке) в панели Network появляется второй запрос к тому же API. Иногда содержимое меняется, иногда экран мигает, иногда запрос только расходует соединение. Цена ошибки — не один лишний round trip. Команда может принять рассинхронизацию разметки за обычное обновление, скрыть её новым ответом и оставить причину в следующем релизе.

\n

Один симптом не доказывает одну причину. Начальный snapshot мог не попасть в bootstrap. Разметка и состояние могли прийти из разных версий. Компонент мог проигнорировать переданные данные. Наконец, fetch мог быть правильным: пользователь сменил фильтр или явно обновил страницу. Сначала нужно назвать trigger и владельца состояния. Только после этого выбирают действие.

\n

Тезис: SSR отдаёт начальное состояние, CSR продолжает работу

\n

SSR (server-side rendering, серверный рендеринг) формирует HTML на сервере. В него обычно попадает состояние для конкретного route, пользователя, locale и набора флагов. CSR (client-side rendering, клиентский рендеринг) подключает обработчики, принимает начальные данные и обслуживает следующие действия в браузере. Эти этапы связаны, но не равны.

\n

Если браузер начинает обычную загрузку до того, как принял initial snapshot, он создаёт вторую операцию вместо продолжения первой. Поэтому вопрос «как убрать fetch» поставлен слишком широко. Правильный вопрос: «какой вход разрешил этот fetch и согласуется ли он с ответом SSR?» Ответ должен быть виден в данных и в порядке переходов, а не только в названии метода.

\n

Механизм: четыре версии одного симптома

\n

Первая ветка — snapshot отсутствует. Сервер отдал HTML, но bootstrap получил пустое состояние. Hook видит cache miss и запускает обычный запрос. Это дефект передачи initial data.

\n

Вторая ветка — snapshot есть, но контракт расходится. Сервер создал HTML для entry-r7, а сериализованное состояние или client code ожидает entry-r8. Причиной могут быть разные ответы upstream, cache boundary, locale, cookie, permission или feature flag. Нельзя исправить такой разрыв без выбора источника истины.

\n

Третья ветка — версии совпадают, но владелец состояния не использует snapshot. Например, effect запускает загрузку при mount без проверки, что данные уже приняты. Здесь запрос лишний, хотя серверный ответ согласован.

\n

Четвёртая ветка — явное действие пользователя. Новый фильтр, переход по странице, нажатие refresh или подписка на новую ревизию меняют вход. Такой fetch не является ошибкой hydrate. Ему нужны отдельный trigger, новый input и понятный владелец.

\n

Полезный контракт можно записать так: markupVersion === snapshot.version разрешает принять snapshot; mismatch сначала становится наблюдаемым событием; новый запрос запускается только после принятия snapshot или после явно названного действия пользователя. Это проектное правило. React задаёт требования к согласованной разметке, но не знает вашу схему версий и не решает, кто владеет бизнес-состоянием.

\n

Минимальный пример сравнения

\n

Ниже — учебная функция. Она не запускает React, не читает DOM и не отправляет HTTP-запрос. Функция показывает только порядок: сначала разобрать snapshot, затем сравнить версии, затем выбрать действие. Значения entry-r7 и entry-r8 придуманы для примера.

\n
function decideHydration({ markupVersion, snapshot, trigger }) {\n  if (!snapshot) {\n    return { action: 'record-missing-snapshot', stateOwner: 'none', fetch: false };\n  }\n\n  if (markupVersion !== snapshot.version) {\n    return {\n      action: 'record-mismatch-before-mutation',\n      expected: markupVersion,\n      received: snapshot.version,\n      stateOwner: 'none',\n      fetch: false,\n    };\n  }\n\n  if (trigger === 'user-refresh' || trigger === 'filter-change') {\n    return { action: 'start-explicit-refresh', stateOwner: 'serialized-snapshot', fetch: true };\n  }\n\n  return { action: 'accept-snapshot', stateOwner: 'serialized-snapshot', fetch: false };\n}\n\nconst result = decideHydration({\n  markupVersion: 'entry-r7',\n  snapshot: { version: 'entry-r7', items: ['A'] },\n  trigger: 'mount',\n});\n\nconsole.log(result);\n// { action: 'accept-snapshot', stateOwner: 'serialized-snapshot', fetch: false }
\n

В mismatch-пути функция не пытается угадать свежий ответ. Она возвращает две версии и останавливается до изменения client state. Это не готовый recovery UX. Приложение должно отдельно решить, показать ли сообщение, повторить чтение по безопасному правилу или передать случай владельцу данных. Важен порядок: диагностика не должна исчезнуть внутри общего loading.

\n
Диагностика повторного client fetch
СимптомПричинаПроверкаДействие
HTML есть, snapshot отсутствуетInitial state не передали в bootstrapНайти сериализованный payload и проверить его наличие до mountПередать один именованный snapshot; повторить сценарий
Markup и payload несут разные версииSSR и client получили разные входы или ответыСохранить обе версии, route, locale, cookie и флаги до mutationОстановить неявное обновление; выбрать recovery path
Версии совпадают, fetch идёт на mountHook игнорирует initial stateПосмотреть owner состояния и условие запуска effectПринять snapshot до запроса; оставить fetch только для нового trigger
Fetch следует за filter-changeПользователь изменил inputСвязать запрос с событием и новым параметромОставить запрос, но отделить его от hydration
Контент меняется без действияГонка snapshot и client response или скрытый refreshСопоставить timestamps, operation id и источник каждого ответаЗащитить порядок применения; не считать последний ответ автоматически верным
\n
Диагностика повторного client fetch: проверка snapshot и версии ведёт к принятию состояния, записи mismatch или отдельному пользовательскому обновлению.
Сначала определяется источник запроса. Совпавший snapshot принимается без второго fetch; mismatch фиксируется до mutation; явное действие пользователя создаёт отдельную операцию.
\n

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

\n

Начните с одного ответа документа. Запишите вход маршрута, контекст пользователя, locale, версию источника, marker в HTML, версию snapshot, имя bootstrap и причину запроса. Если значения нет, так и отметьте. Не подставляйте выдуманные LCP, время ответа или production-результат. Для этой диагностики важнее причинная цепочка, чем красивый waterfall.

\n

Проверьте, что marker и snapshot описывают один источник. Версия без связи с данными мало полезна: она может оставаться одинаковой при разных permission или feature flag. Если эти входы меняют HTML, включите их в контракт или разделите route variant. Если вход не должен влиять на SSR, не позволяйте ему незаметно менять state после hydrate.

\n

Затем найдите место, где создаётся запрос. Смотрите не только на URL. Нужны условие запуска, текущий state, operation id и причина перехода. Если effect вызывает fetch на каждый mount, он должен сначала проверить accepted snapshot. Если запрос приходит от пользовательского события, передайте это событие явно, а не выводите trigger из факта, что компонент уже смонтирован.

\n

Порядок действий

\n
  1. Зафиксируйте симптом: какой HTML виден, когда появляется запрос, меняется ли содержимое и было ли действие пользователя.
  2. Найдите initial snapshot в границе bootstrap. Проверьте, что он относится к тому же route и документу, что и HTML.
  3. Сравните source version, markup marker и serialized version до любого изменения client state.
  4. Если snapshot отсутствует, исправьте передачу данных и добавьте проверку, которая ловит пустой bootstrap.
  5. Если версии различаются, сохраните expected и received, остановите неявный fetch и назначьте владельца recovery. Не закрывайте mismatch свежим ответом.
  6. Если версии совпадают, проверьте effect и cache owner. Принятие snapshot должно предшествовать запросу на mount.
  7. Если запрос вызвал пользовательский input, сохраните trigger и новый параметр. Такой запрос проверяйте как отдельную операцию.
  8. Проверьте отрицательный путь: разный locale, cookie, permission, feature flag, медленный ответ и повторный клик не должны приводить к бесконтрольной гонке.
  9. Сравните до и после число запросов, порядок применения ответа и состояние при mismatch. Считайте исправление завершённым только после проверки критерия готовности.
\n

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

\n

Повторная загрузка уместна, если появилась новая причина. Пользователь сменил фильтр. Истёк явно заданный freshness window. Подписка сообщила о новой ревизии. UI вошёл в режим, которого не было в SSR. В каждом случае сохраняйте новый input и отдельную operation id. Initial snapshot остаётся состоянием первого ответа, а client fetch становится следующей операцией.

\n

Не отключайте SSR только потому, что не нашли владельца второго запроса. Не добавляйте setTimeout, чтобы «дать hydrate закончить». Не подавляйте warning без доказательства, что разметка безопасно различается. Такие меры меняют время симптома, но не объясняют, какой источник сформировал первый экран.

\n

Ограничения

\n

Сравнение строк версий не доказывает равенство всего HTML. Разметка может расходиться из-за timezone, случайных значений, даты, порядка элементов, разных прав или ответа внешней системы. Для каждого входа нужно решить, входит ли он в initial contract. Если нет, перенесите зависимый фрагмент в контролируемую клиентскую ветку.

\n

Учебный код не является React-компонентом и не заменяет browser test. Он не моделирует DOM mutation, concurrent rendering, cache headers, сеть, retry и восстановление после ошибки. Реальный тест должен проверить конкретный renderer, сериализацию и пользовательский сценарий. Нельзя объявлять production-эффектом то, что показала только эта функция.

\n

Материал ограничен историческим API React 17, доступным на январь 2022 года. Современные API и фреймворки могут менять детали bootstrap и hydration. Общий принцип остаётся проверяемым: сначала согласовать initial contract, затем применить snapshot, затем запускать новый запрос по названной причине.

\n

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

\n

Исправление можно считать готовым, если для каждого повторного fetch можно назвать причину, владельца, версию источника и версию snapshot. Тест показывает, что совпавший snapshot не создаёт второй запрос на mount, отсутствующий snapshot остаётся видимым дефектом, mismatch фиксируется до mutation, а filter-change создаёт отдельный запрос с новым input. Если команда может сказать только «браузер обновил данные», причина ещё не установлена.

\n

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

" }