{ "index": 216, "slug": "editorial-2022-01-practice-ssr-csr", "title": "SSR и CSR без рассинхронизации: как принять snapshot и не запустить второй запрос", "excerpt": "HTML уже содержит данные, но после гидрации клиент снова обращается к API и может заменить первый экран. Разбираем контракт snapshot, контекст чтения и явный recovery path для mismatch.", "contentHtml": "
Симптом появляется сразу после загрузки страницы: сервер уже отдал карточку с данными, а после запуска JavaScript браузер повторяет тот же запрос. Иногда ответы совпадают, и ошибка остаётся незаметной. При задержке или изменении записи пользователь видит один текст, затем другой. В DevTools появляются два запроса, а команда не может объяснить, какой из них владел первым экраном. Цена ошибки — лишний сетевой путь, более длинная критическая цепочка и риск тихого рассинхрона между HTML и клиентским состоянием.
\nТезис этой статьи уже, чем общая дискуссия о SSR и CSR: начальное чтение должно иметь одного владельца. Сервер формирует HTML, а приложение может передать рядом сериализованный snapshot. Клиент принимает его только после проверки schema, версии чтения и контекста. При совпадении он пропускает начальный fetch. При несовпадении сначала сохраняет диагностику и не меняет состояние молча. Новый запрос допустим только как явно выбранный recovery path.
\nSSR означает, что HTML формируется на сервере; CSR — что HTML строит клиентский JavaScript. Эти подходы можно совмещать: сервер отдаёт содержимое первого экрана, а клиент добавляет интерактивность. В этой статье под SSR подразумевается серверный рендер динамического ответа, а не любой HTML, подготовленный на этапе сборки. Это ограничение важно: способ получения HTML определяет, какие данные можно связать с ним в snapshot.
\nHydrate — граница, на которой клиент присоединяет логику к уже существующей разметке. React требует, чтобы первый клиентский результат совпадал с серверным HTML, и считает расхождения ошибкой. React не обязан исправлять все отличия атрибутов, поэтому автоматический fetch после hydrate не лечит проблему, а может скрыть её. Snapshot и запрос за ним — не встроенное свойство SSR. Это контракт конкретного приложения или фреймворка.
\nДля одного server response достаточно зафиксировать четыре поля: schema формата, версию исходной записи, контекст чтения и payload. Контекст нельзя свести только к версии записи. HTML может зависеть от пользователя, прав, locale, часового пояса или feature flags. Одинаковая версия записи при разных входах не гарантирует одинаковый результат.
\n| Фаза | Владелец | Проверяем | Допустимое действие | Ошибка |
|---|---|---|---|---|
| SSR/source | server-request | Запись, версия и контекст | Прочитать source и создать HTML-маркер | HTML не связан с чтением |
| Ответ | serialized-snapshot | schema, version, contextKey, payload | Передать initial state рядом с HTML | Поле потерялось при сериализации |
| Hydrate | browser-transition | Маркер HTML и snapshot | Сравнить до изменения состояния | Разные версии приняты молча |
| Client fetch | browser-transition | Явное решение после проверки | Пропустить запрос или начать recovery path | Fetch запускается на каждый mount |
contextKey — это не секрет и не замена авторизации. Это стабильный отпечаток входов, которые влияют на представление. Его можно передавать как opaque-строку, например customer-42|ru-RU|recommendations:off. В production-коде значение нужно экранировать при вставке в HTML и не включать в него чувствительные данные.
Ниже — учебная JavaScript-модель. Она не вызывает React, DOM или сеть: строка markup имитирует HTML-атрибуты, а счётчик запроса представлен полем clientFetch.performed. Модель показывает порядок проверки и не доказывает LCP, latency или поведение конкретного hook.
const source = {\n id: 'entry-42',\n version: 'entry-r7',\n contextKey: 'customer-42|ru-RU|recommendations:off',\n title: 'Training entry',\n status: 'published',\n};\n\nconst snapshot = JSON.stringify({\n schema: 'ssr-snapshot-v1',\n version: source.version,\n contextKey: source.contextKey,\n payload: {\n id: source.id,\n title: source.title,\n status: source.status,\n },\n});\n\nfunction readMarkupContract(markup) {\n const version = markup.match(/data-snapshot-version=['\"]([^'\"]+)['\"]/)?.[1];\n const contextKey = markup.match(/data-snapshot-context=['\"]([^'\"]+)['\"]/)?.[1];\n\n if (!version || !contextKey) return null;\n return { version, contextKey };\n}\n\nfunction parseSnapshot(serialized) {\n try {\n const parsed = JSON.parse(serialized);\n const valid = parsed.schema === 'ssr-snapshot-v1'\n && typeof parsed.version === 'string'\n && typeof parsed.contextKey === 'string'\n && parsed.payload\n && typeof parsed.payload === 'object';\n\n return valid\n ? { ok: true, value: parsed }\n : { ok: false, diagnostic: 'invalid-snapshot-contract' };\n } catch {\n return { ok: false, diagnostic: 'invalid-snapshot-json' };\n }\n}\n\nfunction planHydration(markup, serializedSnapshot, expectedContextKey) {\n const markupContract = readMarkupContract(markup);\n const snapshotResult = parseSnapshot(serializedSnapshot);\n\n if (!markupContract || !snapshotResult.ok) {\n return {\n outcome: 'recovery-required-before-mutation',\n diagnostic: snapshotResult.ok ? 'missing-markup-contract' : snapshotResult.diagnostic,\n clientFetch: { performed: false, action: 'not-started' },\n };\n }\n\n const parsed = snapshotResult.value;\n const mismatch = markupContract.version !== parsed.version\n || markupContract.contextKey !== parsed.contextKey\n || parsed.contextKey !== expectedContextKey;\n\n if (mismatch) {\n return {\n outcome: 'mismatch-recorded-before-mutation',\n diagnostic: {\n markupVersion: markupContract.version,\n snapshotVersion: parsed.version,\n markupContextKey: markupContract.contextKey,\n snapshotContextKey: parsed.contextKey,\n expectedContextKey,\n },\n clientFetch: { performed: false, action: 'recovery-not-selected' },\n };\n }\n\n return {\n outcome: 'snapshot-accepted-without-second-fetch',\n stateOwner: 'serialized-snapshot',\n initialState: parsed.payload,\n clientFetch: { performed: false, action: 'skipped-contract-matched' },\n };\n}\n\nfunction assert(condition, message) {\n if (!condition) throw new Error(message);\n}\n\nconst markup = 'data-snapshot-version=\"entry-r7\" data-snapshot-context=\"customer-42|ru-RU|recommendations:off\"';\nconst matching = planHydration(markup, snapshot, source.contextKey);\nconsole.log(matching.outcome);\nassert(matching.clientFetch.performed === false, 'matching snapshot triggered a fetch');\n\nconst staleMarkup = 'data-snapshot-version=\"entry-r8\" data-snapshot-context=\"customer-42|ru-RU|recommendations:off\"';\nconst mismatch = planHydration(staleMarkup, snapshot, source.contextKey);\nconsole.log(mismatch.diagnostic);\nassert(mismatch.outcome === 'mismatch-recorded-before-mutation', 'mismatch was not recorded');\nassert(mismatch.clientFetch.performed === false, 'mismatch triggered an implicit fetch');\nВ совпадающей ветке snapshot становится входом для initial state только после трёх проверок: маркер HTML совпал с версией snapshot, contextKey совпал между ними, а сам snapshot относится к текущему контексту браузера. Второй запрос не стартует из-за явного результата проверки. В ветке mismatch код сохраняет обе версии, не подменяет старые данные и оставляет recovery path следующему слою.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| После hydrate повторяется запрос за той же записью | Клиент создаёт пустой initial state и не читает snapshot | Сопоставить время HTML, наличие snapshot и первый fetch | Передать snapshot в initial state; fetch запускать только после явного условия |
| Текст меняется сразу после загрузки | HTML и snapshot пришли из разных чтений source | Записать version, contextKey, user, locale и flags | Исправить общий источник либо остановить переход при mismatch |
| Hydrate выдаёт предупреждение | Клиент строит другую разметку | Сравнить server markup и первый client render без fetch | Убрать нестабильное значение из рендера или передать его через snapshot |
| После «исправления» причина не видна | Автоматический fetch маскирует расхождение | Проверить diagnostic и количество запросов | Сначала сохранить evidence, затем выбрать refresh или сообщение |
Проверка должна охватывать не только версию записи. Если HTML зависит от пользователя, языка, часового пояса, прав или эксперимента, эти входы входят в контракт. Одинаковый entry-r7 не означает одинаковый результат для двух пользователей. Snapshot может содержать нормализованный contextKey, но не должен расширять права пользователя и не заменяет серверную авторизацию.
Клиентский fetch после hydrate сам по себе не ошибка: он подходит для намеренного stale-while-revalidate, если команда принимает смену данных и измеряет её. Ошибка — запускать его без условия, которое объясняет, зачем повторять чтение. Иначе причина может быть в кеше HTML, неправильном ключе сериализации, смене пользователя, locale или feature configuration, а новый ответ превратит диагностируемый mismatch в незаметную замену состояния.
\nДругой обход — принять snapshot без проверки. Он экономит запрос, но может показать устаревшие или чужие данные, если документ и payload прошли разные границы кеширования. Третий обход — сравнивать только HTML-строку. Это дорого, хрупко и не объясняет, какие входы породили результат. Сравнивайте версионированный контракт и контекст, а не случайный побочный признак.
\nSSR не делает страницу автоматически быстрой. Сервер может дольше формировать ответ, а сериализованный payload увеличивает документ. CSR остаётся правильным выбором для данных, которые появляются только после действия пользователя, зависят от браузерного API или часто меняются. Не стоит передавать в snapshot секреты, которые нельзя отдавать клиенту. Не стоит принимать данные из snapshot для другого пользователя или другого набора прав.
\nВерсия snapshot не заменяет авторизацию, схему данных и обработку ошибок сети. ETag может участвовать в HTTP-проверке свежести, но для связи HTML с payload лучше иметь явное поле контракта, смысл которого понятен приложению. Для потокового SSR, частичной гидрации и нескольких независимых источников понадобятся отдельные версии и правила владельца. Эта статья не утверждает, что React или любой фреймворк сам реализует описанный протокол.
Решение готово, если на одном документе можно показать пять значений: schema, версию source, маркер HTML, contextKey и действие hydrate. При совпадении тест подтверждает, что snapshot принят, initial state построен из него, а повторный запрос не ушёл. При несовпадении тест подтверждает, что фактические значения записаны, mutation не началась и recovery path назван явно. В браузере это видно в сетевом журнале и диагностической записи. Без этих доказательств «SSR без второго запроса» остаётся предположением.
\n