From 901620c355da7df62546c191cd719758a595e754 Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Thu, 3 Sep 2026 20:24:35 +0300 Subject: [PATCH] Editorial: refine SSR CSR snapshot article 216 --- editorial/agent-rewrites/216.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/editorial/agent-rewrites/216.json b/editorial/agent-rewrites/216.json index 490c173..948bef4 100644 --- a/editorial/agent-rewrites/216.json +++ b/editorial/agent-rewrites/216.json @@ -2,6 +2,6 @@ "index": 216, "slug": "editorial-2022-01-practice-ssr-csr", "title": "SSR и CSR без рассинхронизации: как принять snapshot и не запустить второй запрос", - "excerpt": "Сервер уже показал данные, но клиент снова их загружает и меняет первый экран. Разбираем владельцев состояния, версию snapshot, проверку hydrate и явный путь для mismatch.", - "contentHtml": "

Симптом появляется сразу после загрузки страницы: сервер уже отдал карточку с данными, а после запуска JavaScript браузер повторяет тот же запрос. Иногда ответы совпадают, и ошибка остаётся незаметной. При задержке или изменении записи пользователь видит один текст, затем другой. В DevTools появляются два запроса, а команда не может объяснить, какой из них владел первым экраном. Цена ошибки — лишний сетевой путь, более длинная критическая цепочка и риск тихого рассинхрона между HTML и клиентским состоянием.

\n

Тезис простой: SSR и CSR не должны конкурировать за начальное состояние. Сервер читает source, создаёт HTML и передаёт рядом сериализованный snapshot. Клиент принимает snapshot только после проверки его версии. При совпадении он пропускает начальный fetch. При несовпадении он сначала фиксирует проблему и останавливает неявную мутацию. Новый запрос допустим только как явно выбранное восстановление.

\n

Механизм: четыре фазы и четыре владельца

\n

SSR — это серверный запрос и HTML, который браузер получает первым. HTML показывает результат, но сам по себе не говорит клиентскому коду, из какого чтения он появился. Поэтому ответ должен передать и snapshot начального состояния. Snapshot — не общий кеш приложения. Это данные для конкретного server response.

\n

Hydrate связывает клиентскую логику с уже существующей разметкой. На этой границе нужно сравнить маркер версии HTML и версию snapshot. Версия может быть номером ревизии, ETag, версией набора фильтров или другим значением, которое сервер умеет получить вместе с данными. Нельзя сравнивать только время запроса: два чтения могут иметь одинаковую секунду, но разные права, locale или feature flags.

\n
Кто владеет данными на первом проходе
ФазаВладелецВходДопустимое действиеОшибка
SSR/sourceserver-requestЗапись и её версияПрочитать source и создать HTMLHTML без понятной версии
Ответserialized-snapshotPayload и версия SSRПередать initial state рядом с HTMLВерсия потерялась при сериализации
Hydratebrowser-transitionВерсия разметки и snapshotСравнить до изменения состоянияРазные версии приняты молча
Client fetchbrowser-transitionЯвное решение после проверкиПропустить запрос или начать recovery pathFetch запускается на каждый mount
\n

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

\n

Ниже — учебная JavaScript-модель. Она работает только с объектами и JSON. В ней нет React, Next.js, DOM, сети, таймера и реального браузерного hydrate. Пример показывает порядок владения данными и помогает написать проверку границы. Он не доказывает LCP, latency или поведение конкретного hook.

\n
const source = {\n  id: 'entry-42',\n  version: 'entry-r7',\n  title: 'Training entry',\n  status: 'published',\n};\n\nconst snapshot = JSON.stringify({\n  schema: 'ssr-snapshot-v1',\n  version: source.version,\n  payload: {\n    id: source.id,\n    title: source.title,\n    status: source.status,\n  },\n});\n\nfunction planHydration(markupVersion, serializedSnapshot) {\n  const parsed = JSON.parse(serializedSnapshot);\n\n  if (markupVersion !== parsed.version) {\n    return {\n      outcome: 'mismatch-recorded-before-mutation',\n      diagnostic: {\n        expectedMarkupVersion: markupVersion,\n        serializedVersion: parsed.version,\n      },\n      clientFetch: { performed: false, action: 'not-started' },\n    };\n  }\n\n  return {\n    outcome: 'snapshot-accepted-without-second-fetch',\n    stateOwner: 'serialized-snapshot',\n    initialState: parsed.payload,\n    clientFetch: {\n      performed: false,\n      action: 'skipped-snapshot-version-matched',\n    },\n  };\n}\n\nconst matching = planHydration(source.version, snapshot);\nconsole.log(matching.outcome);\n// snapshot-accepted-without-second-fetch\n\nconst mismatch = planHydration('entry-r8', snapshot);\nconsole.log(mismatch.diagnostic);\n// { expectedMarkupVersion: 'entry-r8', serializedVersion: 'entry-r7' }
\n

В совпадающей ветке snapshot становится входом для initial state. Второй запрос не стартует только потому, что его запуск запрещает правило, а не потому, что эффект случайно выполнился в нужном порядке. В ветке mismatch код не подменяет старые данные новым ответом. Он сохраняет обе версии и оставляет recovery path следующему слою.

\n
Жизненный цикл SSR и CSR: server request создаёт HTML и snapshot, hydrate сравнивает версии, а client fetch пропускается при совпадении и останавливается при mismatch.
Граница между server request, сериализацией, hydrate и client fetch. Красная ветка означает диагностируемое несовпадение, а не автоматическое обновление.
\n

Диагностика: симптом → причина → проверка → действие

\n
Что делать при наблюдаемом симптоме
СимптомПричинаПроверкаДействие
После hydrate повторяется запрос за той же записьюКлиент создаёт пустой initial state и не читает snapshotСопоставить время HTML, snapshot и первый fetchПередать snapshot в initial state; fetch запускать только после явного условия
Текст меняется сразу после загрузкиHTML и snapshot пришли из разных чтений sourceЗаписать обе версии и входы: user, locale, flagsИсправить общий источник либо остановить переход при mismatch
Hydrate выдаёт предупреждениеКлиент строит другую разметкуСравнить server markup и первый client render без fetchУбрать нестабильное значение из рендера или передать его через snapshot
После «исправления» причина не виднаАвтоматический fetch маскирует расхождениеВременно записать markupVersion, serializedVersion и actionСначала сохранить diagnostic, затем выбрать refresh или сообщение
\n

Проверка должна охватывать не только версию записи. Если HTML зависит от пользователя, языка, часового пояса, прав или эксперимента, эти входы тоже входят в контракт. Одинаковый entry-r7 не означает одинаковый результат для двух пользователей. Полезный snapshot хранит либо нормализованные входы, либо достаточно данных, чтобы клиент мог проверить их отдельно.

\n

Порядок внедрения

\n
  1. Зафиксируйте симптом в браузере: URL, время первого HTML, повторный запрос, видимое изменение и входы страницы.
  2. Назовите владельца каждого значения: source, HTML, snapshot, client state и следующий fetch. Не называйте их одним словом «кеш».
  3. Выберите версию, которую сервер получает рядом с source. Передайте её в snapshot вместе с payload и схемой формата.
  4. Поставьте сравнение до mutation и до запуска client fetch. Запишите ожидаемую и фактическую версии.
  5. Для совпадения примите snapshot как initial state и проверьте, что повторный fetch не выполняется.
  6. Для mismatch остановите неявное обновление. Отдельно выберите recovery path: повторить чтение, показать ошибку или отрендерить управляемый fallback.
  7. Добавьте тесты на совпадение, mismatch и изменение входа. В каждом тесте проверяйте не только итоговый экран, но и количество запросов.
  8. Проверьте медленную сеть, отключённый JavaScript, устаревший HTML, разные locale и пользователя без права на часть данных.
\n

Отрицательный путь важнее счастливого

\n

Соблазнительный обход — всегда делать client fetch после mount. Он действительно может показать свежую запись, но стирает вопрос о рассинхроне. Причина может быть в кеше HTML, неправильном ключе сериализации, смене пользователя, locale или feature configuration. Автоматический запрос превращает диагностируемый mismatch в незаметную замену состояния.

\n

Другой обход — принять snapshot без проверки. Он экономит запрос, но может показать устаревшие или чужие данные, если документ и payload прошли разные границы кеширования. Третий обход — сравнивать только HTML-строку. Это дорого, хрупко и не объясняет, какие входы породили результат. Сравнивайте версионированный контракт, а не случайный побочный признак.

\n

Ограничения

\n

SSR не делает страницу автоматически быстрой. Сервер может дольше формировать ответ, а сериализованный payload увеличивает документ. CSR остаётся правильным выбором для данных, которые появляются только после действия пользователя, зависят от браузерного API или часто меняются. Не стоит передавать в snapshot секреты, которые нельзя отдавать клиенту. Не стоит принимать данные из snapshot для другого пользователя или другого набора прав.

\n

Версия snapshot не заменяет авторизацию, схему данных и обработку ошибок сети. Она только обозначает связь между двумя фазами одного чтения. Для потокового SSR, частичной гидрации и нескольких независимых источников понадобятся отдельные версии и правила владельца. Эта статья не утверждает, что React или любой фреймворк сам реализует описанный протокол.

\n

Критерий готовности

\n

Решение готово, если на одном документе можно показать четыре значения: версию source, маркер HTML, версию snapshot и действие hydrate. При совпадении тест подтверждает, что snapshot принят, initial state построен из него, а повторный запрос не ушёл. При несовпадении тест подтверждает, что обе версии записаны, mutation не началась и recovery path назван явно. В браузере это видно в сетевом журнале и диагностической записи. Без этих доказательств «SSR без второго запроса» остаётся предположением.

\n

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

\n" + "excerpt": "HTML уже содержит данные, но после гидрации клиент снова обращается к API и может заменить первый экран. Разбираем контракт snapshot, контекст чтения и явный recovery path для mismatch.", + "contentHtml": "

Симптом появляется сразу после загрузки страницы: сервер уже отдал карточку с данными, а после запуска JavaScript браузер повторяет тот же запрос. Иногда ответы совпадают, и ошибка остаётся незаметной. При задержке или изменении записи пользователь видит один текст, затем другой. В DevTools появляются два запроса, а команда не может объяснить, какой из них владел первым экраном. Цена ошибки — лишний сетевой путь, более длинная критическая цепочка и риск тихого рассинхрона между HTML и клиентским состоянием.

\n

Тезис этой статьи уже, чем общая дискуссия о SSR и CSR: начальное чтение должно иметь одного владельца. Сервер формирует HTML, а приложение может передать рядом сериализованный snapshot. Клиент принимает его только после проверки schema, версии чтения и контекста. При совпадении он пропускает начальный fetch. При несовпадении сначала сохраняет диагностику и не меняет состояние молча. Новый запрос допустим только как явно выбранный recovery path.

\n

Граница ответственности: SSR, CSR и hydrate

\n

SSR означает, что HTML формируется на сервере; CSR — что HTML строит клиентский JavaScript. Эти подходы можно совмещать: сервер отдаёт содержимое первого экрана, а клиент добавляет интерактивность. В этой статье под SSR подразумевается серверный рендер динамического ответа, а не любой HTML, подготовленный на этапе сборки. Это ограничение важно: способ получения HTML определяет, какие данные можно связать с ним в snapshot.

\n

Hydrate — граница, на которой клиент присоединяет логику к уже существующей разметке. React требует, чтобы первый клиентский результат совпадал с серверным HTML, и считает расхождения ошибкой. React не обязан исправлять все отличия атрибутов, поэтому автоматический fetch после hydrate не лечит проблему, а может скрыть её. Snapshot и запрос за ним — не встроенное свойство SSR. Это контракт конкретного приложения или фреймворка.

\n

Контракт начального чтения

\n

Для одного server response достаточно зафиксировать четыре поля: schema формата, версию исходной записи, контекст чтения и payload. Контекст нельзя свести только к версии записи. HTML может зависеть от пользователя, прав, locale, часового пояса или feature flags. Одинаковая версия записи при разных входах не гарантирует одинаковый результат.

\n
Кто владеет данными на первом проходе
ФазаВладелецПроверяемДопустимое действиеОшибка
SSR/sourceserver-requestЗапись, версия и контекстПрочитать source и создать HTML-маркерHTML не связан с чтением
Ответserialized-snapshotschema, version, contextKey, payloadПередать initial state рядом с HTMLПоле потерялось при сериализации
Hydratebrowser-transitionМаркер HTML и snapshotСравнить до изменения состоянияРазные версии приняты молча
Client fetchbrowser-transitionЯвное решение после проверкиПропустить запрос или начать recovery pathFetch запускается на каждый mount
\n

contextKey — это не секрет и не замена авторизации. Это стабильный отпечаток входов, которые влияют на представление. Его можно передавать как opaque-строку, например customer-42|ru-RU|recommendations:off. В production-коде значение нужно экранировать при вставке в HTML и не включать в него чувствительные данные.

\n

Воспроизводимый пример с проверкой schema

\n

Ниже — учебная JavaScript-модель. Она не вызывает React, DOM или сеть: строка markup имитирует HTML-атрибуты, а счётчик запроса представлен полем clientFetch.performed. Модель показывает порядок проверки и не доказывает LCP, latency или поведение конкретного hook.

\n
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
Жизненный цикл SSR и CSR: server request создаёт HTML-маркер и snapshot, hydrate сравнивает версии, а client fetch пропускается при совпадении и останавливается при mismatch.
Граница между server request, сериализацией, hydrate и client fetch. Красная ветка означает диагностируемое несовпадение, а не автоматическое обновление.
\n

Диагностика: симптом → причина → действие

\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 или сообщение
\n

Проверка должна охватывать не только версию записи. Если HTML зависит от пользователя, языка, часового пояса, прав или эксперимента, эти входы входят в контракт. Одинаковый entry-r7 не означает одинаковый результат для двух пользователей. Snapshot может содержать нормализованный contextKey, но не должен расширять права пользователя и не заменяет серверную авторизацию.

\n

Порядок внедрения

\n
  1. Зафиксируйте симптом в браузере: URL, время первого HTML, повторный запрос, видимое изменение и входы страницы.
  2. Назовите владельца каждого значения: source, HTML, snapshot, client state и следующий fetch. Не называйте их одним словом «кеш».
  3. Выберите стабильную версию чтения и contextKey, которые сервер получает рядом с source. Передайте их в snapshot вместе с schema и payload.
  4. Поставьте проверку schema, маркера HTML, version и contextKey до mutation и до запуска client fetch. Запишите ожидаемые и фактические значения.
  5. Для совпадения примите snapshot как initial state и проверьте в Network, что повторный fetch не выполняется.
  6. Для mismatch остановите неявное обновление. Отдельно выберите recovery path: повторить чтение с тем же контекстом, показать ошибку или отрендерить управляемый fallback.
  7. Добавьте тесты на совпадение, mismatch, пропущенный маркер и некорректный JSON. В каждом тесте проверяйте не только итоговый экран, но и количество запросов.
  8. Проверьте медленную сеть, отключённый JavaScript, устаревший HTML, разные locale и пользователя без права на часть данных.
\n

Отрицательный путь важнее счастливого

\n

Клиентский fetch после hydrate сам по себе не ошибка: он подходит для намеренного stale-while-revalidate, если команда принимает смену данных и измеряет её. Ошибка — запускать его без условия, которое объясняет, зачем повторять чтение. Иначе причина может быть в кеше HTML, неправильном ключе сериализации, смене пользователя, locale или feature configuration, а новый ответ превратит диагностируемый mismatch в незаметную замену состояния.

\n

Другой обход — принять snapshot без проверки. Он экономит запрос, но может показать устаревшие или чужие данные, если документ и payload прошли разные границы кеширования. Третий обход — сравнивать только HTML-строку. Это дорого, хрупко и не объясняет, какие входы породили результат. Сравнивайте версионированный контракт и контекст, а не случайный побочный признак.

\n

Ограничения

\n

SSR не делает страницу автоматически быстрой. Сервер может дольше формировать ответ, а сериализованный payload увеличивает документ. CSR остаётся правильным выбором для данных, которые появляются только после действия пользователя, зависят от браузерного API или часто меняются. Не стоит передавать в snapshot секреты, которые нельзя отдавать клиенту. Не стоит принимать данные из snapshot для другого пользователя или другого набора прав.

\n

Версия snapshot не заменяет авторизацию, схему данных и обработку ошибок сети. ETag может участвовать в HTTP-проверке свежести, но для связи HTML с payload лучше иметь явное поле контракта, смысл которого понятен приложению. Для потокового SSR, частичной гидрации и нескольких независимых источников понадобятся отдельные версии и правила владельца. Эта статья не утверждает, что React или любой фреймворк сам реализует описанный протокол.

\n

Критерий готовности

\n

Решение готово, если на одном документе можно показать пять значений: schema, версию source, маркер HTML, contextKey и действие hydrate. При совпадении тест подтверждает, что snapshot принят, initial state построен из него, а повторный запрос не ушёл. При несовпадении тест подтверждает, что фактические значения записаны, mutation не началась и recovery path назван явно. В браузере это видно в сетевом журнале и диагностической записи. Без этих доказательств «SSR без второго запроса» остаётся предположением.

\n

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

\n" }