diff --git a/editorial/agent-rewrites/214.json b/editorial/agent-rewrites/214.json index 7481abe..cf33daa 100644 --- a/editorial/agent-rewrites/214.json +++ b/editorial/agent-rewrites/214.json @@ -1,7 +1,7 @@ { "index": 214, "slug": "editorial-2022-01-field-ssr-csr", - "title": "SSR и CSR: как разобрать повторный fetch после первого экрана", - "excerpt": "HTML уже содержит данные, но после hydrate браузер снова обращается к API. Разбираем четыре причины симптома, порядок проверки и безопасный путь для mismatch.", - "contentHtml": "
Проблема заметна в браузере: сервер уже отдал список, пользователь видит первый экран, а сразу после hydrate в Network появляется второй запрос к тому же API. Иногда содержимое меняется, иногда экран мигает, иногда запрос только расходует соединение. Цена ошибки — не один лишний round trip. Команда может принять рассинхронизацию разметки за обычное обновление, скрыть её новым ответом и оставить причину в следующем релизе.
\nОдин симптом не доказывает одну причину. Initial snapshot мог не попасть в bootstrap. Разметка и состояние могли прийти из разных версий. Компонент мог проигнорировать переданные данные. Наконец, fetch мог быть правильным: пользователь сменил фильтр или явно обновил страницу. Сначала нужно назвать trigger и владельца состояния. Только после этого выбирают действие.
\nSSR формирует HTML на сервере. В него обычно попадает представление состояния, которое сервер получил для конкретного route, пользователя, locale и набора флагов. CSR подключает обработчики, восстанавливает состояние и обслуживает следующие действия в браузере. Эти этапы связаны, но не равны.
\nЕсли браузер начинает обычную загрузку до того, как принял initial snapshot, он создаёт вторую операцию вместо продолжения первой. Поэтому вопрос «как убрать fetch» поставлен слишком широко. Правильный вопрос: «какой вход разрешил этот fetch и согласуется ли он с ответом SSR?» Ответ должен быть виден в данных и в порядке переходов, а не только в названии метода.
\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. Нельзя исправить такой разрыв без выбора источника истины.
Третья ветка — версии совпадают, но владелец состояния не использует snapshot. Например, effect запускает загрузку при mount без проверки, что данные уже приняты. Здесь запрос лишний, хотя серверный ответ согласован.
\nЧетвёртая ветка — явное действие пользователя. Новый фильтр, переход по странице, нажатие refresh или подписка на новую ревизию меняют вход. Такой fetch не является ошибкой hydrate. Ему нужны отдельный trigger, новый input и понятный владелец.
\nПолезный контракт можно записать так: serverVersion === serializedVersion разрешает принять snapshot; mismatch сначала становится наблюдаемым событием; новый запрос запускается только после принятия snapshot или после named user trigger. Это проектное правило. React задаёт требования к согласованной разметке, но не знает вашу схему версий и не решает, кто владеет бизнес-состоянием.
Ниже — учебная функция. Она не запускает React, не читает DOM и не отправляет HTTP-запрос. Функция показывает только порядок: сначала разобрать snapshot, затем сравнить версии, затем выбрать действие. Значения entry-r7 и entry-r8 придуманы для примера.
function decideHydration({ markupVersion, snapshot, trigger }) {\n if (!snapshot) {\n return { action: 'record-missing-snapshot', 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 fetch: false,\n };\n }\n\n if (trigger === 'user-refresh' || trigger === 'filter-change') {\n return { action: 'start-explicit-refresh', fetch: true };\n }\n\n return { action: 'accept-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', fetch: false }\nВ mismatch-пути функция не пытается угадать свежий ответ. Она возвращает две версии и останавливается до изменения client state. Это не готовый recovery UX. Приложение должно отдельно решить, показать ли сообщение, повторить чтение по безопасному правилу или передать случай владельцу данных. Важен порядок: диагностика не должна исчезнуть внутри общего loading.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| HTML есть, snapshot отсутствует | Initial state не передали в bootstrap | Найти сериализованный payload и проверить его наличие до mount | Передать один именованный snapshot; повторить сценарий |
| Markup и payload несут разные версии | SSR и client получили разные входы или ответы | Сохранить обе версии, route, locale, cookie и флаги до mutation | Остановить неявное обновление; выбрать recovery path |
| Версии совпадают, fetch идёт на mount | Hook игнорирует initial state | Посмотреть owner состояния и условие запуска effect | Принять snapshot до запроса; оставить fetch только для нового trigger |
| Fetch следует за filter-change | Пользователь изменил input | Связать запрос с событием и новым параметром | Оставить запрос, но отделить его от hydration |
| Контент меняется без действия | Гонка snapshot и client response или скрытый refresh | Сопоставить timestamps, operation id и источник каждого ответа | Защитить порядок применения; не считать последний ответ автоматически верным |
Начните с одного document response. Запишите route input, identity контекста, locale, source version, marker в HTML, serialized version, имя bootstrap и trigger запроса. Если значения нет, так и отметьте. Не подставляйте выдуманные 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Повторная загрузка уместна, если появилась новая причина. Пользователь сменил фильтр. Истёк явно заданный freshness window. Подписка сообщила о новой ревизии. UI вошёл в режим, которого не было в SSR. В каждом случае сохраняйте новый input и отдельную operation id. Initial snapshot остаётся состоянием первого ответа, а client fetch становится следующей операцией.
\nНе отключайте SSR только потому, что не нашли владельца второго запроса. Не добавляйте setTimeout, чтобы «дать hydrate закончить». Не подавляйте warning без доказательства, что разметка безопасно различается. Такие меры меняют время симптома, но не объясняют, какой источник сформировал первый экран.
Сравнение строк версий не доказывает равенство всего 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Разбор готов, если для каждого повторного fetch можно назвать trigger, owner, source version и serialized version. Тест показывает, что совпавший snapshot не создаёт второй запрос на mount, отсутствующий snapshot остаётся видимым дефектом, mismatch фиксируется до mutation, а filter-change создаёт отдельный запрос с новым input. Если команда может сказать только «браузер обновил данные», причина ещё не установлена.
\nПроблема заметна в браузере: сервер уже отдал список, пользователь видит первый экран, а сразу после hydrate (подключения клиентского кода к готовой разметке) в панели Network появляется второй запрос к тому же API. Иногда содержимое меняется, иногда экран мигает, иногда запрос только расходует соединение. Цена ошибки — не один лишний round trip. Команда может принять рассинхронизацию разметки за обычное обновление, скрыть её новым ответом и оставить причину в следующем релизе.
\nОдин симптом не доказывает одну причину. Начальный snapshot мог не попасть в bootstrap. Разметка и состояние могли прийти из разных версий. Компонент мог проигнорировать переданные данные. Наконец, fetch мог быть правильным: пользователь сменил фильтр или явно обновил страницу. Сначала нужно назвать trigger и владельца состояния. Только после этого выбирают действие.
\nSSR (server-side rendering, серверный рендеринг) формирует HTML на сервере. В него обычно попадает состояние для конкретного route, пользователя, locale и набора флагов. CSR (client-side rendering, клиентский рендеринг) подключает обработчики, принимает начальные данные и обслуживает следующие действия в браузере. Эти этапы связаны, но не равны.
\nЕсли браузер начинает обычную загрузку до того, как принял initial snapshot, он создаёт вторую операцию вместо продолжения первой. Поэтому вопрос «как убрать fetch» поставлен слишком широко. Правильный вопрос: «какой вход разрешил этот fetch и согласуется ли он с ответом SSR?» Ответ должен быть виден в данных и в порядке переходов, а не только в названии метода.
\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. Нельзя исправить такой разрыв без выбора источника истины.
Третья ветка — версии совпадают, но владелец состояния не использует snapshot. Например, effect запускает загрузку при mount без проверки, что данные уже приняты. Здесь запрос лишний, хотя серверный ответ согласован.
\nЧетвёртая ветка — явное действие пользователя. Новый фильтр, переход по странице, нажатие refresh или подписка на новую ревизию меняют вход. Такой fetch не является ошибкой hydrate. Ему нужны отдельный trigger, новый input и понятный владелец.
\nПолезный контракт можно записать так: markupVersion === snapshot.version разрешает принять snapshot; mismatch сначала становится наблюдаемым событием; новый запрос запускается только после принятия snapshot или после явно названного действия пользователя. Это проектное правило. React задаёт требования к согласованной разметке, но не знает вашу схему версий и не решает, кто владеет бизнес-состоянием.
Ниже — учебная функция. Она не запускает React, не читает DOM и не отправляет HTTP-запрос. Функция показывает только порядок: сначала разобрать snapshot, затем сравнить версии, затем выбрать действие. Значения entry-r7 и entry-r8 придуманы для примера.
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.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| HTML есть, snapshot отсутствует | Initial state не передали в bootstrap | Найти сериализованный payload и проверить его наличие до mount | Передать один именованный snapshot; повторить сценарий |
| Markup и payload несут разные версии | SSR и client получили разные входы или ответы | Сохранить обе версии, route, locale, cookie и флаги до mutation | Остановить неявное обновление; выбрать recovery path |
| Версии совпадают, fetch идёт на mount | Hook игнорирует initial state | Посмотреть owner состояния и условие запуска effect | Принять snapshot до запроса; оставить fetch только для нового trigger |
| Fetch следует за filter-change | Пользователь изменил input | Связать запрос с событием и новым параметром | Оставить запрос, но отделить его от hydration |
| Контент меняется без действия | Гонка snapshot и client response или скрытый refresh | Сопоставить timestamps, operation id и источник каждого ответа | Защитить порядок применения; не считать последний ответ автоматически верным |
Начните с одного ответа документа. Запишите вход маршрута, контекст пользователя, 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Повторная загрузка уместна, если появилась новая причина. Пользователь сменил фильтр. Истёк явно заданный freshness window. Подписка сообщила о новой ревизии. UI вошёл в режим, которого не было в SSR. В каждом случае сохраняйте новый input и отдельную operation id. Initial snapshot остаётся состоянием первого ответа, а client fetch становится следующей операцией.
\nНе отключайте SSR только потому, что не нашли владельца второго запроса. Не добавляйте setTimeout, чтобы «дать hydrate закончить». Не подавляйте warning без доказательства, что разметка безопасно различается. Такие меры меняют время симптома, но не объясняют, какой источник сформировал первый экран.
Сравнение строк версий не доказывает равенство всего 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Исправление можно считать готовым, если для каждого повторного fetch можно назвать причину, владельца, версию источника и версию snapshot. Тест показывает, что совпавший snapshot не создаёт второй запрос на mount, отсутствующий snapshot остаётся видимым дефектом, mismatch фиксируется до mutation, а filter-change создаёт отдельный запрос с новым input. Если команда может сказать только «браузер обновил данные», причина ещё не установлена.
\n