revise January 2022 SSR CSR articles
Build and deploy / deploy (push) Successful in 13s

This commit is contained in:
2026-07-31 13:29:02 +03:00
parent 15a25c35d3
commit f7f88fb2a5
7 changed files with 890 additions and 1 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Производство редакционных партий
На 31 июля 2026 года строгий аудит проходит 142 из 358 созданных материалов. Остальные 216 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
На 31 июля 2026 года строгий аудит проходит 145 из 358 созданных материалов. Остальные 213 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
## Одна партия
+142
View File
@@ -0,0 +1,142 @@
# П47 — SSR/CSR, январь 2022: self-review sidecar-пакета
## Граница пакета
Подготовлены только пять файлов, без изменения registry, README, `articles.json`, Git и чужих черновиков:
1. `web/scripts/upgrade-2022-01.mjs`;
2. `web/public/assets/editorial/2022/ssr-csr-lifecycle-2022.svg`;
3. `web/public/assets/editorial/2022/ssr-csr-state-ownership-2022.svg`;
4. `web/public/assets/editorial/2022/ssr-csr-diagnosis-2022.svg`;
5. этот review.
Скрипт экспортирует только overlay-поля: `slug`, `title`, `categories`, `cover`, `excerpt`, `readingMinutes`, `contentHtml`. В нём нет `date` и `author`. `--print-revisions` выводит тот же массив, что import-safe export `revisions`; это проверяет `audit:draft`.
## Историческая рамка
Материал ограничен январём 2022 года. Проверены три первичных официальных источника:
- [ReactDOM.hydrate — immutable documentation snapshot от 23 декабря 2021](https://github.com/reactjs/react.dev/blob/b41b1dc35679c01c3252e7d512ce28c5e100d0a4/content/docs/reference-react-dom.md#hydrate);
- [ReactDOMServer.renderToString — immutable documentation snapshot от 23 декабря 2021](https://github.com/reactjs/react.dev/blob/b41b1dc35679c01c3252e7d512ce28c5e100d0a4/content/docs/reference-react-dom-server.md#rendertostring);
- [React 17.0.2 release record от 22 марта 2021](https://github.com/react/react/releases/tag/v17.0.2).
Все три URL прошли HTTP preflight с итоговым `200`. Из документации взят только факт: `hydrate` работает с существующей server-rendered разметкой, ожидает согласованное server/client content, а mismatch следует исправлять. Схема `version + serialized snapshot`, labels fixture и recovery policy — учебные и проектные решения, а не API React. В статьях нет React 18, `hydrateRoot`, Next.js App Router, server components или документов позже января 2022 года.
## Проход 1 — голос, объём и структура
Проверял: М5 начала 2022 года — frontend-системщик начинает с пользовательского сценария, называет границу данных, отделяет наблюдаемое от решения и оставляет путь проверки без выдуманных production-метрик.
| Slug | Body, знаки | Роль | Структура | Итог |
| --- | ---: | --- | --- | --- |
| `editorial-2022-01-practice-ssr-csr` | 6 964 | Воспроизводимый путь | 6 `h2`, таблица, SVG, fixture-код, маршрут | Принято |
| `editorial-2022-01-mechanism-ssr-csr` | 7 791 | Причинная модель | 6 `h2`, таблица, SVG, fixture-код, маршрут | Принято |
| `editorial-2022-01-field-ssr-csr` | 8 250 | Диагностика случая | 6 `h2`, таблица, SVG, fixture-код, маршрут | Принято |
Во всех трёх первых двух абзацах есть конкретная проблема и её цена. В каждом тексте отделены: факт исторической документации, локальная fixture и решение проекта. Проверен язык: вместо общего «SSR быстрее» оставлены наблюдаемые формулировки о source, version, snapshot, hydrate и условии client fetch. Шаблонные обороты из редакционного аудита отсутствуют.
Команда:
```text
npm run audit:draft -- scripts/upgrade-2022-01.mjs
```
Результат: все три статьи прошли диапазон 5 000–15 000, доступную таблицу, рисунок с alt/подписью, пример кода, ordered route, два и более внешних источника и import-safe overlay.
## Проход 2 — техническая fixture и assertions
Проверял: согласованность примеров со скриптом и запрет подменять учебный объект реализацией React/Next.js.
`runSsrCsrFixture()` работает только с local object, array, `JSON.stringify` и `JSON.parse`. В ней нет DOM, HTTP, `fetch`, browser API, таймера, файла, сети, базы или framework runtime. `markupVersion` назван учебным marker, а не внутренней проверкой React.
Выполнено:
```text
node --check web/scripts/upgrade-2022-01.mjs
node web/scripts/upgrade-2022-01.mjs --verify-fixture
```
Результат fixture: **12/12 assertions**.
Покрыты свойства:
1. same version пропускает второй client fetch;
2. mismatch фиксируется до markup/client-state mutation;
3. mismatch остаётся видимым и содержит expected/serialized version;
4. mismatch не запускает автоматический client fetch;
5. serialized version равна версии server source;
6. matching path называет `serialized-snapshot` владельцем initial state;
7. фазы `ssr/source`, `serialized-snapshot`, `hydrate`, `client-fetch` не сливаются;
8. у этих фаз разные action labels;
9. owners server, snapshot и browser transition названы отдельно;
10. внешняя работа не выполняется;
11. схема не заявлена как реализация React или Next.js;
12. повторный matching plan детерминирован.
Все snippets в статьях импортируют `runSsrCsrFixture` и читают реальные поля `serverEnvelope`, `matchingHydration`, `mismatchHydration`, `stateOwner`, `mutation`, `clientFetch`, `steps` и `assertions`.
## Проход 3 — источники, историческая точность и visual
Проверял: фиксированную дату источников, доступность ссылок, XML/SVG safety и визуальное объяснение на узком экране.
- Исторические ссылки зафиксированы immutable commit от 2021-12-23 и release 2021-03-22; источник не опирается на mutable current API wording.
- `xmllint --noout` прошёл для всех трёх SVG.
- Safety scan не нашёл `<script>`, `<foreignObject>`, external `href`/`src` и raster `data:`.
- Каждый SVG содержит `title`, `desc`, `role="img"`, содержательный article-level `alt` и `figcaption`.
- Sharp-рендеры шириной 375 px просмотрены вручную: lifecycle различает ветки match/mismatch, ownership показывает hand-off вместо общего cache, diagnosis разделяет четыре причины повторного fetch. Контраст, направления стрелок и границы блоков не обрезаны.
Не заявлялись и не выполнялись production build, интеграция overlay в registry, изменение базы статей, публикация или push. В материале нет реальных browser trace, LCP, инцидентов, метрик или результатов проекта.
## Вердикт
Sidecar-пакет готов для независимого редакторского приёма и последующей интеграции. До интеграции нужен отдельный проход, который добавит импорт/спред в registry и обновит счётчик архива; в этом пакете эти файлы намеренно не затронуты.
## Независимое редакторское ревью и интеграция
Статус: **принято и подключено в editorial overlay 31 июля 2026 года**.
### 1. Факты и техника
- Исторический source-check подтверждает commit React docs
<code>b41b1dc35679c01c3252e7d512ce28c5e100d0a4</code> от 23 декабря 2021.
В нём <code>hydrate()</code> присоединяет listeners к существующей
server-rendered разметке, ожидает идентичный server/client content и
называет mismatch ошибкой; <code>renderToString()</code> возвращает initial
HTML. Release API React подтверждает <code>v17.0.2</code> от 22 марта 2021.
Статьи не переносят эти факты на произвольную схему version/snapshot.
- После независимого import-проверки fixture дал 12 из 12 истинных assertions.
Matching path использует <code>serialized-snapshot</code> как учебного
владельца и не запускает second fetch; mismatch записывается до mutation,
остаётся видимым и не скрывается fetch. Это contract учебного object, а не
React/Next.js API и не утверждение о поведении DOM.
- <code>node --check</code> и draft audit прошли: body — 6 964, 7 791 и
8 250 знаков. У CLI fixture формат вывода — строка PASS, поэтому result
дополнительно подтверждён прямым import/export check, без подмены JSON
parsing. После интеграции strict audit прошёл для всех трёх slug: 1 figure,
1 table и 1 code example в каждой статье. В registry 136 revisions; три
январские slug единственные, а <code>date</code>/<code>author</code> в
overlay отсутствуют.
### 2. Редактура и голос
- Тексты перечитаны как начало 2022 года: автор объясняет ownership initial
state, версию snapshot и error boundary, но не заявляет опыт React
framework migration или production performance study. Условия проекта
отделены от документации React, а следующий шаг требует зафиксировать один
собственный source/version/recovery contract.
- Схема не обещает «SSR быстрее» и не называет автоматический client fetch
исправлением mismatch. Это сохраняет короткий технический язык и цену
ошибки: скрыть неравенство server/client state вместо выбора recovery path.
### 3. Визуал и выпуск
- Три SVG повторно прошли XML и safety scan: нет <code>script</code>,
<code>foreignObject</code>, внешних URL или raster data. Рендеры Sharp в
ширину 375 px просмотрены вручную: lifecycle, hand-off ownership и четыре
ветки diagnosis читаемы; стрелки и подписи не обрезаны.
- <code>npm run build</code> завершилась с code 0 и сгенерировала 374
статические страницы; <code>git diff --check</code> прошёл. Неприменимые
npm warnings о пользовательских <code>store-dir</code>,
<code>cache-dir</code> и <code>public-hoist-pattern</code> не изменялись.
Итог: январские статьи заменяют архивные записи через overlay. После этой
интеграции строгий аудит проходит **145 из 358**, остаётся **213**.
+2
View File
@@ -43,6 +43,7 @@ import { revisions as september2021Revisions } from '../scripts/upgrade-2021-09.
import { revisions as october2021Revisions } from '../scripts/upgrade-2021-10.mjs';
import { revisions as november2021Revisions } from '../scripts/upgrade-2021-11.mjs';
import { revisions as december2021Revisions } from '../scripts/upgrade-2021-12.mjs';
import { revisions as january2022Revisions } from '../scripts/upgrade-2022-01.mjs';
// This layer replaces archived source entries without losing their stable slug and date.
export const editorialRevisions = [
@@ -91,4 +92,5 @@ export const editorialRevisions = [
...october2021Revisions,
...november2021Revisions,
...december2021Revisions,
...january2022Revisions,
];
@@ -0,0 +1,73 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800" role="img" aria-labelledby="title desc">
<title id="title">Диагностика повторного client fetch после SSR</title>
<desc id="desc">Схема начинает с повторного client fetch после hydrate и разделяет четыре причины: snapshot отсутствует, snapshot совпадает, но игнорирован, версии markup и snapshot не совпадают, либо fetch вызван явным пользовательским действием. Для mismatch показано сохранение версий и остановка до mutation.</desc>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#516579"/>
</marker>
<marker id="arrow-red" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#bd3d4b"/>
</marker>
</defs>
<rect width="1200" height="800" fill="#f7fafc"/>
<rect x="42" y="34" width="1116" height="78" rx="18" fill="#102a43"/>
<text x="78" y="74" fill="#ffffff" font-family="Arial, sans-serif" font-size="30" font-weight="700">Повторный client fetch: сначала классифицируйте переход</text>
<text x="78" y="99" fill="#cbd8e6" font-family="Arial, sans-serif" font-size="18">Одинаковый запрос в DevTools может означать четыре разные причины и четыре разных действия.</text>
<rect x="365" y="148" width="470" height="100" rx="16" fill="#fff8df" stroke="#c99b2d" stroke-width="3"/>
<text x="402" y="189" fill="#785900" font-family="Arial, sans-serif" font-size="24" font-weight="700">После hydrate начался client fetch</text>
<text x="432" y="220" fill="#243b53" font-family="Arial, sans-serif" font-size="17">Сначала собрать owner, trigger, markup version и snapshot version.</text>
<line x1="600" y1="249" x2="600" y2="298" stroke="#516579" stroke-width="4" marker-end="url(#arrow)"/>
<line x1="126" y1="300" x2="1074" y2="300" stroke="#516579" stroke-width="4"/>
<line x1="150" y1="300" x2="150" y2="338" stroke="#516579" stroke-width="4" marker-end="url(#arrow)"/>
<line x1="436" y1="300" x2="436" y2="338" stroke="#516579" stroke-width="4" marker-end="url(#arrow)"/>
<line x1="764" y1="300" x2="764" y2="338" stroke="#bd3d4b" stroke-width="5" marker-end="url(#arrow-red)"/>
<line x1="1050" y1="300" x2="1050" y2="338" stroke="#516579" stroke-width="4" marker-end="url(#arrow)"/>
<rect x="52" y="348" width="196" height="220" rx="16" fill="#e6f0fb" stroke="#6b9fd1" stroke-width="3"/>
<text x="76" y="387" fill="#164a78" font-family="Arial, sans-serif" font-size="20" font-weight="700">1. No snapshot</text>
<text x="76" y="423" fill="#243b53" font-family="Arial, sans-serif" font-size="16">Bootstrap starts</text>
<text x="76" y="447" fill="#243b53" font-family="Arial, sans-serif" font-size="16">with empty state.</text>
<line x1="76" y1="472" x2="224" y2="472" stroke="#bfd2e6" stroke-width="2"/>
<text x="76" y="505" fill="#164a78" font-family="Arial, sans-serif" font-size="16" font-weight="700">Action</text>
<text x="76" y="530" fill="#243b53" font-family="Arial, sans-serif" font-size="15">Pass named initial</text>
<text x="76" y="551" fill="#243b53" font-family="Arial, sans-serif" font-size="15">snapshot to bootstrap.</text>
<rect x="280" y="348" width="312" height="220" rx="16" fill="#e7f7f0" stroke="#16805c" stroke-width="3"/>
<text x="305" y="387" fill="#0f5c42" font-family="Arial, sans-serif" font-size="20" font-weight="700">2. Versions match</text>
<text x="305" y="423" fill="#243b53" font-family="Arial, sans-serif" font-size="16">Snapshot exists, but</text>
<text x="305" y="447" fill="#243b53" font-family="Arial, sans-serif" font-size="16">a hook ignores it.</text>
<line x1="305" y1="472" x2="563" y2="472" stroke="#b9e6d5" stroke-width="2"/>
<text x="305" y="505" fill="#0f5c42" font-family="Arial, sans-serif" font-size="16" font-weight="700">Action</text>
<text x="305" y="530" fill="#243b53" font-family="Arial, sans-serif" font-size="15">Use accepted snapshot</text>
<text x="305" y="551" fill="#243b53" font-family="Arial, sans-serif" font-size="15">as initial state; skip fetch.</text>
<rect x="624" y="332" width="282" height="252" rx="16" fill="#fdecef" stroke="#bd3d4b" stroke-width="4"/>
<text x="650" y="373" fill="#8d2636" font-family="Arial, sans-serif" font-size="20" font-weight="700">3. Mismatch</text>
<text x="650" y="409" fill="#6e2530" font-family="Arial, sans-serif" font-size="16">markup: entry-r8</text>
<text x="650" y="433" fill="#6e2530" font-family="Arial, sans-serif" font-size="16">snapshot: entry-r7</text>
<line x1="650" y1="455" x2="878" y2="455" stroke="#efb4bc" stroke-width="2"/>
<text x="650" y="488" fill="#8d2636" font-family="Arial, sans-serif" font-size="16" font-weight="700">Action before mutation</text>
<text x="650" y="514" fill="#6e2530" font-family="Arial, sans-serif" font-size="15">record both versions</text>
<text x="650" y="536" fill="#6e2530" font-family="Arial, sans-serif" font-size="15">keep client fetch stopped</text>
<text x="650" y="558" fill="#6e2530" font-family="Arial, sans-serif" font-size="15">choose recovery explicitly</text>
<rect x="938" y="348" width="210" height="220" rx="16" fill="#eef2ff" stroke="#727ac9" stroke-width="3"/>
<text x="963" y="387" fill="#353e87" font-family="Arial, sans-serif" font-size="20" font-weight="700">4. New trigger</text>
<text x="963" y="423" fill="#243b53" font-family="Arial, sans-serif" font-size="16">User changes a</text>
<text x="963" y="447" fill="#243b53" font-family="Arial, sans-serif" font-size="16">filter or refreshes.</text>
<line x1="963" y1="472" x2="1122" y2="472" stroke="#cfd1ef" stroke-width="2"/>
<text x="963" y="505" fill="#353e87" font-family="Arial, sans-serif" font-size="16" font-weight="700">Action</text>
<text x="963" y="530" fill="#243b53" font-family="Arial, sans-serif" font-size="15">Keep fetch, name the</text>
<text x="963" y="551" fill="#243b53" font-family="Arial, sans-serif" font-size="15">trigger and new input.</text>
<line x1="764" y1="586" x2="764" y2="640" stroke="#bd3d4b" stroke-width="5" marker-end="url(#arrow-red)"/>
<rect x="468" y="654" width="592" height="84" rx="16" fill="#ffffff" stroke="#bd3d4b" stroke-width="3"/>
<text x="500" y="691" fill="#8d2636" font-family="Arial, sans-serif" font-size="21" font-weight="700">Do not hide mismatch with an automatic fetch</text>
<text x="500" y="718" fill="#6e2530" font-family="Arial, sans-serif" font-size="16">Diagnostic first; recovery is a separate project decision and test.</text>
<rect x="52" y="654" width="352" height="84" rx="16" fill="#102a43"/>
<text x="76" y="690" fill="#ffffff" font-family="Arial, sans-serif" font-size="18" font-weight="700">Fixture boundary</text>
<text x="76" y="716" fill="#cbd8e6" font-family="Arial, sans-serif" font-size="15">No DOM, network, trace or framework runtime.</text>
</svg>

After

Width:  |  Height:  |  Size: 7.1 KiB

@@ -0,0 +1,70 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 780" role="img" aria-labelledby="title desc">
<title id="title">Переход SSR к CSR с проверкой версии snapshot</title>
<desc id="desc">Server request читает source версии entry-r7, создаёт HTML и serialized snapshot. Hydrate сравнивает version разметки и snapshot. Совпадение пропускает client fetch, а mismatch записывается до mutation и требует отдельного recovery path.</desc>
<defs>
<marker id="arrow-slate" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#516579"/>
</marker>
<marker id="arrow-green" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#16805c"/>
</marker>
<marker id="arrow-red" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#bd3d4b"/>
</marker>
</defs>
<rect width="1200" height="780" fill="#f7fafc"/>
<rect x="42" y="34" width="1116" height="78" rx="18" fill="#102a43"/>
<text x="78" y="74" fill="#ffffff" font-family="Arial, sans-serif" font-size="30" font-weight="700">SSR → serialized snapshot → hydrate</text>
<text x="78" y="99" fill="#cbd8e6" font-family="Arial, sans-serif" font-size="18">Версия связывает первый HTML и initial state; client fetch остаётся отдельным действием.</text>
<text x="70" y="160" fill="#526779" font-family="Arial, sans-serif" font-size="18" font-weight="700">SERVER</text>
<text x="712" y="160" fill="#526779" font-family="Arial, sans-serif" font-size="18" font-weight="700">BROWSER</text>
<line x1="645" y1="144" x2="645" y2="694" stroke="#d4dee8" stroke-width="3" stroke-dasharray="8 10"/>
<rect x="62" y="192" width="246" height="178" rx="16" fill="#e6f0fb" stroke="#6b9fd1" stroke-width="3"/>
<text x="88" y="230" fill="#164a78" font-family="Arial, sans-serif" font-size="22" font-weight="700">1. SSR/source</text>
<text x="88" y="268" fill="#243b53" font-family="Arial, sans-serif" font-size="18">owner: server-request</text>
<text x="88" y="298" fill="#243b53" font-family="Arial, sans-serif" font-size="18">read-versioned-source</text>
<rect x="88" y="318" width="192" height="34" rx="8" fill="#ffffff"/>
<text x="106" y="341" fill="#164a78" font-family="Arial, sans-serif" font-size="18" font-weight="700">entry-r7</text>
<rect x="354" y="192" width="246" height="178" rx="16" fill="#eef2ff" stroke="#727ac9" stroke-width="3"/>
<text x="380" y="230" fill="#353e87" font-family="Arial, sans-serif" font-size="22" font-weight="700">2. Server response</text>
<text x="380" y="268" fill="#243b53" font-family="Arial, sans-serif" font-size="18">HTML marker: entry-r7</text>
<text x="380" y="298" fill="#243b53" font-family="Arial, sans-serif" font-size="18">serialized snapshot: entry-r7</text>
<rect x="380" y="318" width="191" height="34" rx="8" fill="#ffffff"/>
<text x="398" y="341" fill="#353e87" font-family="Arial, sans-serif" font-size="17" font-weight="700">version + payload</text>
<line x1="309" y1="280" x2="351" y2="280" stroke="#516579" stroke-width="4" marker-end="url(#arrow-slate)"/>
<line x1="602" y1="280" x2="699" y2="280" stroke="#516579" stroke-width="4" marker-end="url(#arrow-slate)"/>
<rect x="718" y="192" width="420" height="178" rx="16" fill="#fff8df" stroke="#c99b2d" stroke-width="3"/>
<text x="746" y="230" fill="#785900" font-family="Arial, sans-serif" font-size="22" font-weight="700">3. Hydrate comparison</text>
<text x="746" y="268" fill="#243b53" font-family="Arial, sans-serif" font-size="18">markup version = snapshot version?</text>
<rect x="746" y="291" width="164" height="46" rx="10" fill="#ffffff" stroke="#e3c364" stroke-width="2"/>
<text x="778" y="321" fill="#785900" font-family="Arial, sans-serif" font-size="20" font-weight="700">MATCH</text>
<rect x="930" y="291" width="176" height="46" rx="10" fill="#ffffff" stroke="#e3c364" stroke-width="2"/>
<text x="952" y="321" fill="#785900" font-family="Arial, sans-serif" font-size="20" font-weight="700">MISMATCH</text>
<path d="M 828 372 C 828 438 779 454 689 454" fill="none" stroke="#16805c" stroke-width="5" marker-end="url(#arrow-green)"/>
<text x="713" y="423" fill="#16805c" font-family="Arial, sans-serif" font-size="18" font-weight="700">same version</text>
<rect x="408" y="468" width="372" height="160" rx="16" fill="#e7f7f0" stroke="#16805c" stroke-width="3"/>
<text x="436" y="507" fill="#0f5c42" font-family="Arial, sans-serif" font-size="23" font-weight="700">4a. Accept snapshot</text>
<text x="436" y="546" fill="#1f4d3c" font-family="Arial, sans-serif" font-size="18">state owner: serialized-snapshot</text>
<text x="436" y="578" fill="#1f4d3c" font-family="Arial, sans-serif" font-size="18">client fetch: skipped</text>
<text x="436" y="605" fill="#1f4d3c" font-family="Arial, sans-serif" font-size="16">No second request in the teaching model.</text>
<path d="M 1018 372 C 1018 430 1018 442 1018 466" fill="none" stroke="#bd3d4b" stroke-width="5" marker-end="url(#arrow-red)"/>
<text x="1033" y="424" fill="#bd3d4b" font-family="Arial, sans-serif" font-size="18" font-weight="700">different version</text>
<rect x="822" y="468" width="316" height="160" rx="16" fill="#fdecef" stroke="#bd3d4b" stroke-width="3"/>
<text x="850" y="507" fill="#8d2636" font-family="Arial, sans-serif" font-size="23" font-weight="700">4b. Stop and record</text>
<text x="850" y="546" fill="#6e2530" font-family="Arial, sans-serif" font-size="18">diagnostic: r8 ≠ r7</text>
<text x="850" y="578" fill="#6e2530" font-family="Arial, sans-serif" font-size="18">mutation: not-started</text>
<text x="850" y="605" fill="#6e2530" font-family="Arial, sans-serif" font-size="16">Choose a project recovery path.</text>
<rect x="62" y="672" width="1076" height="64" rx="14" fill="#ffffff" stroke="#d4dee8" stroke-width="2"/>
<circle cx="100" cy="704" r="10" fill="#16805c"/>
<text x="122" y="710" fill="#243b53" font-family="Arial, sans-serif" font-size="17">Green: the snapshot is accepted; automatic client fetch is skipped.</text>
<circle cx="706" cy="704" r="10" fill="#bd3d4b"/>
<text x="728" y="710" fill="#243b53" font-family="Arial, sans-serif" font-size="17">Red: mismatch stays visible before mutation.</text>
</svg>

After

Width:  |  Height:  |  Size: 6.5 KiB

@@ -0,0 +1,78 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 780" role="img" aria-labelledby="title desc">
<title id="title">Владение данными на границе SSR и CSR</title>
<desc id="desc">Временная шкала показывает: server request владеет source, server response — HTML и serialized snapshot, hydrate проверяет их version, затем snapshot становится owner initial state. Client fetch является отдельной новой операцией. Прямая передача server source в client state перечёркнута.</desc>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#516579"/>
</marker>
<marker id="arrow-green" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#16805c"/>
</marker>
</defs>
<rect width="1200" height="780" fill="#f7fafc"/>
<rect x="42" y="34" width="1116" height="78" rx="18" fill="#102a43"/>
<text x="78" y="74" fill="#ffffff" font-family="Arial, sans-serif" font-size="30" font-weight="700">Кто владеет состоянием в каждой фазе</text>
<text x="78" y="99" fill="#cbd8e6" font-family="Arial, sans-serif" font-size="18">Source, HTML, serialized snapshot и client state живут в разное время и не являются одним cache.</text>
<text x="70" y="164" fill="#526779" font-family="Arial, sans-serif" font-size="18" font-weight="700">TIME →</text>
<line x1="164" y1="157" x2="1120" y2="157" stroke="#aebdcb" stroke-width="4" marker-end="url(#arrow)"/>
<rect x="60" y="208" width="210" height="184" rx="16" fill="#e6f0fb" stroke="#6b9fd1" stroke-width="3"/>
<text x="84" y="248" fill="#164a78" font-family="Arial, sans-serif" font-size="22" font-weight="700">SSR/source</text>
<text x="84" y="283" fill="#243b53" font-family="Arial, sans-serif" font-size="18">owner</text>
<text x="84" y="313" fill="#164a78" font-family="Arial, sans-serif" font-size="19" font-weight="700">server-request</text>
<text x="84" y="350" fill="#243b53" font-family="Arial, sans-serif" font-size="17">version: entry-r7</text>
<text x="84" y="375" fill="#243b53" font-family="Arial, sans-serif" font-size="15">Lives for one server read.</text>
<rect x="312" y="208" width="248" height="184" rx="16" fill="#eef2ff" stroke="#727ac9" stroke-width="3"/>
<text x="338" y="248" fill="#353e87" font-family="Arial, sans-serif" font-size="22" font-weight="700">Server response</text>
<text x="338" y="283" fill="#243b53" font-family="Arial, sans-serif" font-size="17">HTML marker</text>
<text x="338" y="310" fill="#353e87" font-family="Arial, sans-serif" font-size="18" font-weight="700">entry-r7</text>
<text x="338" y="342" fill="#243b53" font-family="Arial, sans-serif" font-size="17">serialized snapshot</text>
<text x="338" y="369" fill="#353e87" font-family="Arial, sans-serif" font-size="18" font-weight="700">version + payload</text>
<rect x="602" y="208" width="240" height="184" rx="16" fill="#fff8df" stroke="#c99b2d" stroke-width="3"/>
<text x="628" y="248" fill="#785900" font-family="Arial, sans-serif" font-size="22" font-weight="700">Hydrate</text>
<text x="628" y="283" fill="#243b53" font-family="Arial, sans-serif" font-size="17">owner</text>
<text x="628" y="310" fill="#785900" font-family="Arial, sans-serif" font-size="18" font-weight="700">browser-transition</text>
<text x="628" y="345" fill="#243b53" font-family="Arial, sans-serif" font-size="16">Compare markup version</text>
<text x="628" y="370" fill="#243b53" font-family="Arial, sans-serif" font-size="16">with snapshot version.</text>
<rect x="884" y="208" width="256" height="184" rx="16" fill="#e7f7f0" stroke="#16805c" stroke-width="3"/>
<text x="910" y="248" fill="#0f5c42" font-family="Arial, sans-serif" font-size="22" font-weight="700">Initial client state</text>
<text x="910" y="283" fill="#243b53" font-family="Arial, sans-serif" font-size="17">owner after match</text>
<text x="910" y="310" fill="#0f5c42" font-family="Arial, sans-serif" font-size="18" font-weight="700">serialized-snapshot</text>
<text x="910" y="345" fill="#243b53" font-family="Arial, sans-serif" font-size="16">No automatic second fetch.</text>
<text x="910" y="370" fill="#243b53" font-family="Arial, sans-serif" font-size="16">Later updates are new work.</text>
<line x1="271" y1="300" x2="308" y2="300" stroke="#516579" stroke-width="4" marker-end="url(#arrow)"/>
<line x1="561" y1="300" x2="598" y2="300" stroke="#516579" stroke-width="4" marker-end="url(#arrow)"/>
<line x1="843" y1="300" x2="880" y2="300" stroke="#16805c" stroke-width="5" marker-end="url(#arrow-green)"/>
<text x="847" y="276" fill="#16805c" font-family="Arial, sans-serif" font-size="16" font-weight="700">versions match</text>
<rect x="60" y="466" width="520" height="192" rx="16" fill="#ffffff" stroke="#d4dee8" stroke-width="3"/>
<text x="88" y="506" fill="#243b53" font-family="Arial, sans-serif" font-size="22" font-weight="700">Правильная передача initial state</text>
<text x="88" y="545" fill="#526779" font-family="Arial, sans-serif" font-size="18">source → HTML + snapshot → version check → client state</text>
<rect x="90" y="572" width="148" height="48" rx="9" fill="#e6f0fb"/>
<text x="112" y="603" fill="#164a78" font-family="Arial, sans-serif" font-size="18" font-weight="700">source</text>
<line x1="239" y1="596" x2="288" y2="596" stroke="#516579" stroke-width="4" marker-end="url(#arrow)"/>
<rect x="294" y="572" width="164" height="48" rx="9" fill="#eef2ff"/>
<text x="317" y="603" fill="#353e87" font-family="Arial, sans-serif" font-size="18" font-weight="700">snapshot</text>
<line x1="459" y1="596" x2="510" y2="596" stroke="#16805c" stroke-width="4" marker-end="url(#arrow-green)"/>
<text x="102" y="645" fill="#526779" font-family="Arial, sans-serif" font-size="15">Snapshot is a hand-off, not a long-lived cache.</text>
<rect x="642" y="466" width="498" height="192" rx="16" fill="#fdecef" stroke="#bd3d4b" stroke-width="3"/>
<text x="670" y="506" fill="#8d2636" font-family="Arial, sans-serif" font-size="22" font-weight="700">Неверное сокращение границы</text>
<text x="670" y="545" fill="#6e2530" font-family="Arial, sans-serif" font-size="18">source → client state без snapshot и version check</text>
<rect x="674" y="572" width="146" height="48" rx="9" fill="#e6f0fb"/>
<text x="696" y="603" fill="#164a78" font-family="Arial, sans-serif" font-size="18" font-weight="700">source</text>
<line x1="824" y1="596" x2="1002" y2="596" stroke="#bd3d4b" stroke-width="6"/>
<line x1="865" y1="570" x2="950" y2="622" stroke="#bd3d4b" stroke-width="7"/>
<line x1="950" y1="570" x2="865" y2="622" stroke="#bd3d4b" stroke-width="7"/>
<rect x="1005" y="572" width="112" height="48" rx="9" fill="#fdecef"/>
<text x="1021" y="603" fill="#8d2636" font-family="Arial, sans-serif" font-size="17" font-weight="700">state</text>
<text x="674" y="645" fill="#6e2530" font-family="Arial, sans-serif" font-size="15">The browser cannot own a completed server request.</text>
<rect x="60" y="700" width="1080" height="42" rx="12" fill="#102a43"/>
<text x="84" y="728" fill="#ffffff" font-family="Arial, sans-serif" font-size="17">Project decision: choose the marker and recovery path. Documentation fact: server markup and client render must agree.</text>
</svg>

After

Width:  |  Height:  |  Size: 7.5 KiB

+524
View File
@@ -0,0 +1,524 @@
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function paragraph(text) {
return '<p>' + text + '</p>';
}
function heading(text) {
return '<h2>' + text + '</h2>';
}
function codeBlock(lines) {
return '<pre><code>' + escapeHtml(Array.isArray(lines) ? lines.join('\n') : lines) + '</code></pre>';
}
function figure(src, alt, caption) {
return '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
}
function orderedList(items) {
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
}
function dataTable(caption, headers, rows) {
const head = '<thead><tr>' + headers.map((header) => '<th scope="col">' + header + '</th>').join('') + '</tr></thead>';
const body = '<tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody>';
return '<div class="table-scroll"><table><caption>' + caption + '</caption>' + head + body + '</table></div>';
}
function sourceList(items) {
return '<ul>' + items.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener noreferrer">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
}
function plainText(content) {
return content
.replace(/<[^>]+>/g, ' ')
.replaceAll('&nbsp;', ' ')
.replaceAll('&quot;', '"')
.replaceAll('&#039;', "'")
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&amp;', '&')
.replace(/\s+/g, ' ')
.trim();
}
function bodyText(content) {
return plainText(content.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''));
}
function createRevision(meta, bodyParts, sources) {
if (sources.length < 2) {
throw new Error(meta.slug + ': нужно минимум два первичных или официальных источника');
}
const contentHtml = bodyParts.join('\n') + '\n' + heading('Проверяемые источники') + '\n' + sourceList(sources);
const proseLength = bodyText(contentHtml).length;
if (proseLength < 5000 || proseLength > 15000) {
throw new Error(meta.slug + ': основной текст вне 5 000–15 000 знаков: ' + proseLength);
}
return { ...meta, contentHtml, proseLength };
}
const reactDomHydrateSnapshot = {
title: 'React documentation: ReactDOM.hydrate, historical source snapshot',
url: 'https://github.com/reactjs/react.dev/blob/b41b1dc35679c01c3252e7d512ce28c5e100d0a4/content/docs/reference-react-dom.md#hydrate',
note: 'первичный снимок официальной документации на commit от 23 декабря 2021 года. Он требует одинаковое содержимое server/client и советует считать mismatch ошибкой; protocol версии из fixture в документе не задан.',
};
const reactDomServerSnapshot = {
title: 'React documentation: ReactDOMServer.renderToString, historical source snapshot',
url: 'https://github.com/reactjs/react.dev/blob/b41b1dc35679c01c3252e7d512ce28c5e100d0a4/content/docs/reference-react-dom-server.md#rendertostring',
note: 'первичный снимок того же официального репозитория от 23 декабря 2021 года: server renderer отдаёт initial HTML, а hydrate присоединяется к существующей разметке. Он не описывает эту учебную JavaScript-схему.',
};
const react1702Release = {
title: 'React 17.0.2: versioned release record',
url: 'https://github.com/react/react/releases/tag/v17.0.2',
note: 'официальная release record от 22 марта 2021 года. Она фиксирует верхнюю границу версии React, о которой можно говорить в январе 2022; материал не использует API React 18 или Next.js App Router.',
};
const commonSources = [reactDomHydrateSnapshot, reactDomServerSnapshot, react1702Release];
const practiceExample = [
'import { runSsrCsrFixture } from "./upgrade-2022-01.mjs";',
'',
'const fixture = runSsrCsrFixture();',
'const plan = fixture.matchingHydration;',
'',
'console.log(plan.stateOwner);',
'// serialized-snapshot',
'console.log(plan.clientFetch.action);',
'// skipped-snapshot-version-matched',
'',
'if (plan.clientFetch.performed) {',
' throw new Error("matching snapshot must not start a second fetch");',
'}',
'',
'// This is an in-memory teaching model, not a React or Next.js integration.',
].join('\n');
const mechanismExample = [
'import { runSsrCsrFixture } from "./upgrade-2022-01.mjs";',
'',
'const fixture = runSsrCsrFixture();',
'const snapshot = JSON.parse(fixture.serverEnvelope.serializedSnapshot);',
'',
'console.log(fixture.serverEnvelope.source.version);',
'console.log(snapshot.version);',
'console.log(fixture.matchingHydration.steps.map((step) => step.phase));',
'// ["ssr/source", "serialized-snapshot", "hydrate", "client-fetch"]',
'',
'if (!fixture.assertions.serializedVersionEqualsServerSource) {',
' throw new Error("source and serialized snapshot lost one version boundary");',
'}',
].join('\n');
const diagnosisExample = [
'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");',
'}',
].join('\n');
/**
* Детерминированная учебная модель границы SSR/CSR.
*
* Она использует только локальные object, array, JSON.stringify и JSON.parse.
* Здесь нет DOM, React, Next.js, HTTP, fetch, browser API, таймера, файла,
* базы, сети или project state. `markupVersion` — учебный маркер уже
* отрендерованного HTML, а не проверка, которую делает React.
*/
function createServerEnvelope() {
const source = Object.freeze({
id: 'training-entry-42',
version: 'entry-r7',
title: 'Training entry',
status: 'published',
});
const snapshot = Object.freeze({
schema: 'training-ssr-snapshot-v1',
version: source.version,
payload: Object.freeze({
id: source.id,
title: source.title,
status: source.status,
}),
});
const serializedSnapshot = JSON.stringify(snapshot);
return Object.freeze({
source,
markupVersion: source.version,
serializedSnapshot,
boundary: Object.freeze({
sourceOwner: 'server-request',
snapshotOwner: 'serialized-snapshot',
purpose: 'carry one declared source version from SSR into a teaching hydration plan',
}),
});
}
/**
* Возвращает план, но не меняет DOM или state. При mismatch план останавливает
* учебный переход до mutation и не запускает клиентскую загрузку автоматически.
*/
export function planHydration(serverEnvelope, markupVersion) {
const snapshot = JSON.parse(serverEnvelope.serializedSnapshot);
const versionsMatch = snapshot.version === markupVersion;
const baseSteps = [
Object.freeze({
phase: 'ssr/source',
owner: 'server-request',
action: 'read-versioned-source',
version: serverEnvelope.source.version,
}),
Object.freeze({
phase: 'serialized-snapshot',
owner: 'serialized-snapshot',
action: 'serialize-version-and-payload',
version: snapshot.version,
}),
Object.freeze({
phase: 'hydrate',
owner: 'browser-transition',
action: 'compare-markup-version-with-snapshot-version',
expectedVersion: markupVersion,
serializedVersion: snapshot.version,
result: versionsMatch ? 'match' : 'mismatch',
}),
];
if (!versionsMatch) {
return Object.freeze({
outcome: 'mismatch-recorded-before-mutation',
stateOwner: 'none-until-explicit-recovery',
snapshot,
diagnostic: Object.freeze({
kind: 'version-mismatch',
expectedMarkupVersion: markupVersion,
serializedVersion: snapshot.version,
requiredNextStep: 'choose-and-implement-an-explicit-project-recovery-path',
}),
mutation: Object.freeze({
markup: 'not-started',
clientState: 'not-started',
}),
clientFetch: Object.freeze({
action: 'not-started-mismatch-remains-visible',
performed: false,
}),
steps: Object.freeze([
...baseSteps,
Object.freeze({
phase: 'client-fetch',
owner: 'browser-transition',
action: 'not-started-mismatch-remains-visible',
performed: false,
}),
]),
});
}
return Object.freeze({
outcome: 'snapshot-accepted-without-second-fetch',
stateOwner: 'serialized-snapshot',
snapshot,
diagnostic: null,
mutation: Object.freeze({
markup: 'not-modelled',
clientState: 'not-modelled',
}),
clientFetch: Object.freeze({
action: 'skipped-snapshot-version-matched',
performed: false,
}),
steps: Object.freeze([
...baseSteps,
Object.freeze({
phase: 'client-fetch',
owner: 'browser-transition',
action: 'skipped-snapshot-version-matched',
performed: false,
}),
]),
});
}
export function runSsrCsrFixture() {
const serverEnvelope = createServerEnvelope();
const matchingHydration = planHydration(serverEnvelope, serverEnvelope.markupVersion);
const mismatchHydration = planHydration(serverEnvelope, 'entry-r8');
const repeatedMatchingHydration = planHydration(serverEnvelope, serverEnvelope.markupVersion);
const expectedPhases = ['ssr/source', 'serialized-snapshot', 'hydrate', 'client-fetch'];
const matchingPhases = matchingHydration.steps.map((step) => step.phase);
const mismatchPhases = mismatchHydration.steps.map((step) => step.phase);
const executionBoundary = Object.freeze({
network: 'not-used',
files: 'not-used',
dom: 'not-used',
framework: 'not-used',
implementationClaim: 'training-schema-not-react-or-nextjs',
});
const assertions = Object.freeze({
sameVersionSkipsSecondFetch: matchingHydration.outcome === 'snapshot-accepted-without-second-fetch'
&& matchingHydration.clientFetch.performed === false
&& matchingHydration.clientFetch.action === 'skipped-snapshot-version-matched',
mismatchRecordedBeforeMutation: mismatchHydration.outcome === 'mismatch-recorded-before-mutation'
&& mismatchHydration.mutation.markup === 'not-started'
&& mismatchHydration.mutation.clientState === 'not-started',
mismatchIsVisibleInsteadOfSilenced: mismatchHydration.diagnostic?.kind === 'version-mismatch'
&& mismatchHydration.diagnostic.expectedMarkupVersion === 'entry-r8'
&& mismatchHydration.diagnostic.serializedVersion === 'entry-r7',
mismatchDoesNotStartClientFetch: mismatchHydration.clientFetch.performed === false
&& mismatchHydration.clientFetch.action === 'not-started-mismatch-remains-visible',
serializedVersionEqualsServerSource: JSON.parse(serverEnvelope.serializedSnapshot).version === serverEnvelope.source.version
&& serverEnvelope.markupVersion === serverEnvelope.source.version,
matchingPathNamesSerializedSnapshotAsOwner: matchingHydration.stateOwner === 'serialized-snapshot'
&& matchingHydration.snapshot.payload.id === serverEnvelope.source.id,
actionPhasesStayDistinct: JSON.stringify(matchingPhases) === JSON.stringify(expectedPhases)
&& JSON.stringify(mismatchPhases) === JSON.stringify(expectedPhases),
actionNamesStayDistinct: matchingHydration.steps[0].action === 'read-versioned-source'
&& matchingHydration.steps[1].action === 'serialize-version-and-payload'
&& matchingHydration.steps[2].action === 'compare-markup-version-with-snapshot-version'
&& matchingHydration.steps[3].action === 'skipped-snapshot-version-matched',
serverSnapshotHydrateAndClientOwnersAreNamed: matchingHydration.steps[0].owner === 'server-request'
&& matchingHydration.steps[1].owner === 'serialized-snapshot'
&& matchingHydration.steps[2].owner === 'browser-transition'
&& matchingHydration.steps[3].owner === 'browser-transition',
noExternalWorkOccurs: executionBoundary.network === 'not-used'
&& executionBoundary.files === 'not-used'
&& executionBoundary.dom === 'not-used'
&& executionBoundary.framework === 'not-used',
schemaIsNotPresentedAsReactOrNextImplementation: executionBoundary.implementationClaim === 'training-schema-not-react-or-nextjs'
&& serverEnvelope.boundary.purpose.includes('teaching hydration plan'),
repeatedMatchingPlanIsDeterministic: JSON.stringify(matchingHydration) === JSON.stringify(repeatedMatchingHydration),
});
return Object.freeze({
kind: 'deterministic-ssr-csr-boundary-fixture',
serverEnvelope,
matchingHydration,
mismatchHydration,
executionBoundary,
assertions,
});
}
const practiceArticle = createRevision(
{
slug: 'editorial-2022-01-practice-ssr-csr',
title: 'SSR и CSR без второго запроса: версия snapshot как граница',
categories: ['Frontend', 'Производительность'],
cover: '/assets/editorial/2022/ssr-csr-lifecycle-2022.svg',
excerpt: 'Практический маршрут для страницы, где SSR уже получил данные, а CSR должен принять тот же snapshot, проверить его версию и не запускать второй запрос вслепую.',
readingMinutes: 12,
},
[
paragraph('Проблема обычно видна уже в браузере: сервер отдал карточку, после загрузки JavaScript клиент снова начинает получать те же данные. Пока ответ совпадает, ошибка кажется безобидной. Цена проявляется при изменении источника между двумя фазами: пользователь сначала видит один текст, затем другой, а команда не может сказать, какой запрос владел состоянием первого экрана.'),
paragraph('В этой заметке не будем обещать «правильный SSR для всех фреймворков». Нужна более узкая договорённость: серверный запрос, сериализованный snapshot, переход hydrate и возможная клиентская загрузка имеют разные роли. Если snapshot подтверждён той же версией, он владеет начальным состоянием и второй запрос не стартует. Если версия не совпала, расхождение записывается до mutation, а путь восстановления выбирается явно.'),
heading('Сначала назовите четыре перехода'),
paragraph('SSR — это не синоним «данные уже есть». Серверный обработчик читает source и создаёт HTML. Затем рядом с разметкой появляется сериализованное описание начального состояния. Hydrate читает уже отданный snapshot и присоединяет клиентскую логику к тому, что показал сервер. Client fetch — отдельное действие после этой проверки, а не побочный эффект каждого mount.'),
paragraph('Версия нужна не как декоративное поле. Она отвечает на конкретный вопрос: HTML, который видит пользователь, и payload, который готов принять клиент, описывают один источник или два разных? Для учебного примера достаточно строки <code>entry-r7</code>. В проекте это может быть revision записи, ETag, версия набора фильтров или иной контракт, который вы умеете получить и сравнить на обеих сторонах границы.'),
dataTable(
'Владение данными на первом проходе страницы',
['Фаза', 'Владелец', 'Вход', 'Допустимое действие', 'Что считать ошибкой'],
[
['SSR/source', '<code>server-request</code>', 'версионированная запись', 'прочитать source и отдать HTML', 'выдать HTML без понятной версии source'],
['serialized snapshot', '<code>serialized-snapshot</code>', 'payload и версия SSR', 'сохранить начальное состояние рядом с HTML', 'потерять версию при сериализации'],
['hydrate', '<code>browser-transition</code>', 'версия разметки и snapshot', 'сравнить до изменения состояния', 'молча принять разные версии'],
['client fetch', '<code>browser-transition</code>', 'явное решение после проверки', 'пропустить при совпадении или начать выбранный recovery path', 'запустить повторный запрос «на всякий случай»'],
],
),
heading('Практическая схема: snapshot принимается один раз'),
paragraph('Ниже не React-код. Это локальная fixture из этого пакета: она создаёт объект server envelope, сериализует version вместе с payload и строит план hydrate. У неё нет DOM, сети, файла, таймера или browser API. Поэтому пример нельзя выдавать за трассу браузера и нельзя по нему судить о LCP, latency либо поведении конкретного hook. Его можно использовать для другого: удержать на месте контракт владения данными.'),
codeBlock(practiceExample),
paragraph('У совпадающего пути свойство <code>stateOwner</code> равно <code>serialized-snapshot</code>. Поле <code>clientFetch.performed</code> остаётся <code>false</code>. Это не магическая оптимизация и не механизм React. Это проектное правило учебной модели: если HTML и snapshot указывают на один version, начальный экран не запрашивает второй источник. В настоящем приложении такое правило нужно связать с конкретной точкой, где создаётся запрос и где потребляется initial state.'),
figure(
'/assets/editorial/2022/ssr-csr-lifecycle-2022.svg',
'Схема жизненного цикла: server request читает source версии entry-r7, затем формирует HTML и serialized snapshot той же версии. Hydrate сравнивает версии. При совпадении client fetch пропущен; при mismatch переход останавливается до mutation и создаёт диагностическую запись.',
'Четыре прямоугольника разделяют source, сериализацию, hydrate и client fetch. Красная ветка не является reload: она фиксирует mismatch и требует отдельного проектного решения.',
),
heading('Маршрут: симптом → причина → проверка → действие'),
orderedList([
'<strong>Симптом.</strong> После первого HTML появляется второй вызов для тех же данных, либо экран меняет текст сразу после hydrate.',
'<strong>Причина.</strong> Компонент не знает, что initial state уже пришёл с SSR, или HTML и snapshot принадлежат разным версиям source.',
'<strong>Проверка.</strong> Выпишите четыре фазы из таблицы. Для каждой назовите owner, version, место записи и место чтения. Не объединяйте source и client cache в одно неименованное «состояние».',
'<strong>Проверка границы.</strong> До любой mutation сравните version из server markup с version сериализованного payload. Fixture показывает этот порядок в <code>steps</code>.',
'<strong>Действие при совпадении.</strong> Передайте snapshot в initial state и оставьте client fetch пропущенным. Защитите это отдельной проверкой, а не надеждой на порядок эффектов.',
'<strong>Действие при mismatch.</strong> Сохраните обе версии и остановите неявный переход. Затем отдельно выберите refresh, сообщение пользователю или controlled rerender — это уже решение конкретного проекта.',
]),
heading('Где закрепить правило в приложении'),
paragraph('У правила должны быть два владельца кода. Первый владелец формирует server response: он обязан вернуть HTML и snapshot из одного чтения либо обозначить, что это не гарантируется. Второй владелец — точка client bootstrap: она обязана принять snapshot только после сравнения и не создавать параллельную загрузку по умолчанию. Если эти обязанности спрятаны в нескольких hook, баг становится похож на проблему кеша, хотя на деле пропал контракт между фазами.'),
paragraph('Полезно дать snapshot имя, которое нельзя спутать с долгоживущим cache. <code>initialPageSnapshot</code> означает «данные для данного server response», а не «последняя известная запись во всём приложении». Его версия живёт столько же, сколько документ и первый клиентский переход. Дальнейшие обновления могут использовать другой cache и другие правила. Смешение этих уровней рождает второй fetch: один слой считает себя пустым, хотя другой уже показал данные.'),
heading('Что факт, что fixture и что проектное решение'),
paragraph('Факт документации React периода 17: <code>hydrate</code> предназначен для markup, отрендеренного server renderer, и ожидает одинаковое содержимое сервера и клиента; mismatch следует исправлять. Документация не предписывает сравнивать строковую версию и не обещает отсутствие повторных запросов. Fixture — локальная модель этой проверки, а не реализация <code>ReactDOM.hydrate</code>. Project decision — где хранить version, какой recovery path выбрать и когда разрешать новую загрузку.'),
paragraph('Ограничение важнее красивой схемы: equal version не доказывает равенство всех полей, прав пользователя, locale или feature configuration. Если эти факторы меняют видимый HTML, они должны стать частью контракта snapshot либо отдельным условием перехода. Сначала добавьте один такой фактор в fixture и тест, потом переносите правило в компонент. Так число «необъяснимых» запросов уменьшается без выдуманного production-результата.'),
heading('Историческая граница'),
paragraph('Материал помещён в январь 2022 года. Для терминов использованы только первичные снимки React documentation от 23 декабря 2021 года и release React 17.0.2 от марта 2021-го. Здесь нет API React 18, Next.js App Router и поздних схем server components. Даже в рамках React 17 version protocol остаётся учебным и проектным слоем поверх требования согласованного server/client markup.'),
],
commonSources,
);
const mechanismArticle = createRevision(
{
slug: 'editorial-2022-01-mechanism-ssr-csr',
title: 'Граница SSR и CSR: source, snapshot и hydrate не один cache',
categories: ['Frontend', 'Производительность'],
cover: '/assets/editorial/2022/ssr-csr-state-ownership-2022.svg',
excerpt: 'Причинная модель для ситуации, когда HTML уже содержит данные, но клиент не знает их версию и повторяет загрузку. Разделяем владение source, snapshot, hydrate и клиентским запросом.',
readingMinutes: 13,
},
[
paragraph('Проблема начинается не с самого SSR, а с ложного равенства: «данные на сервере», «данные в HTML» и «данные в клиентском состоянии» называют одним cache. Из-за этого client code не отличает уже подтверждённый snapshot от пустого состояния и запускает второй запрос. Цена — две конкурирующие версии первого экрана: одна пришла вместе с документом, другая может прийти позже и незаметно заменить её.'),
paragraph('Причинная модель должна разделить не библиотеки, а переходы данных. Source отвечает за актуальную запись на момент server request. Serialized snapshot переносит результат этого чтения к документу. Hydrate решает, можно ли принять этот результат как initial state. Client fetch создаёт следующую версию только после явного правила. Пока эти четыре роли не разведены, оптимизация превращается в угадывание порядка effect, а mismatch маскируется новым ответом.'),
heading('Один запрос создаёт два артефакта'),
paragraph('Server renderer возвращает HTML. Для клиентского кода этого недостаточно: он видит элементы, но не знает, какой source сформировал их и какими входами управлялся. Поэтому рядом нужен сериализованный payload с версией. Это не дублирование бизнес-данных ради удобства. HTML — представление для первого показа, snapshot — переносимое начальное состояние для следующей фазы. Их общая version — точка, на которой можно проверить, что они говорят об одном чтении.'),
paragraph('В fixture объект <code>serverEnvelope</code> хранит <code>source</code>, <code>markupVersion</code> и строку <code>serializedSnapshot</code>. Первые два значения берутся из <code>entry-r7</code>, а JSON содержит schema, version и payload. Такая форма намеренно скучна. Она показывает, что version должна быть рядом с данными, а не жить в отдельном глобальном флаге, который client code может прочитать уже после того, как начал запрашивать новое состояние.'),
codeBlock(mechanismExample),
heading('Владелец состояния меняется по фазам'),
paragraph('После SSR source больше не должен считаться владельцем initial view: серверный request завершён. Пока hydrate не подтвердил snapshot, браузер не должен считать владельцем ни client cache, ни произвольный hook. При совпадении owner становится <code>serialized-snapshot</code>. Он не становится владельцем всей будущей истории записи; он только объясняет, откуда взялось начальное состояние этой страницы. Дальнейшее обновление — новый переход с отдельными условиями.'),
dataTable(
'Причинная модель: что меняется, а что только переносится',
['Сущность', 'Создаётся где', 'Кто читает', 'Время жизни', 'Неверное упрощение'],
[
['source', 'server request', 'server renderer', 'одно чтение на стороне сервера', 'считать его доступным client code'],
['HTML', 'server renderer', 'пользователь и hydrate', 'до следующего browser update', 'считать HTML достаточным initial state'],
['serialized snapshot', 'server response', 'hydrate', 'до принятия initial state', 'считать его общим cache приложения'],
['client state', 'после принятия snapshot', 'интерактивный UI', 'по правилам конкретного проекта', 'создавать его пустым независимо от snapshot'],
['client fetch', 'после явного условия', 'browser transition', 'новый запрос', 'запускать автоматически при каждом hydrate'],
],
),
figure(
'/assets/editorial/2022/ssr-csr-state-ownership-2022.svg',
'Схема владения: server request владеет source entry-r7, server response передаёт HTML и serialized snapshot, hydrate сравнивает marker с snapshot. При совпадении initial state принадлежит snapshot; client fetch остаётся отдельной поздней веткой.',
'Стрелки показывают передачу данных, а не общий cache. Перечёркнутый путь «source сразу в client state» указывает на потерянную границу времени и владельца.',
),
heading('Почему mismatch нельзя исправлять молча'),
paragraph('Рассмотрим учебный разрыв: markup ожидает <code>entry-r8</code>, а snapshot содержит <code>entry-r7</code>. В fixture <code>planHydration</code> сначала создаёт diagnostic с обеими версиями. Только после этого она возвращает <code>mutation: not-started</code> и <code>clientFetch.performed: false</code>. Такой порядок не утверждает, что React проверяет версии именно так. Он не даёт проектному коду спрятать смысл ошибки за незаметным reload.'),
paragraph('Соблазнительный обход — сразу запрашивать source повторно. Он может нарисовать свежие данные, но стирает важный вопрос: почему документ и payload не совпали? Причина может быть в кешировании документа, неправильном ключе сериализации, подстановке пользователя, locale или ином owner. Автоматический fetch не делает эти различия безопаснее. Он только заменяет диагностируемое расхождение вторым состоянием, которое нельзя связать с первым HTML.'),
heading('Маршрут: симптом → причина → проверка → действие'),
orderedList([
'<strong>Симптом.</strong> Devtools показывает повторный запрос после hydrate, либо UI меняется, хотя пользователь ещё ничего не сделал.',
'<strong>Причина.</strong> Initial state создаётся независимо от snapshot, либо версия HTML и payload собирается из разных чтений source.',
'<strong>Проверка.</strong> На одном документе выпишите source version, markup marker, serialized version и момент первого client fetch. Если хотя бы одно значение нельзя назвать, контракта ещё нет.',
'<strong>Проверка модели.</strong> Запустите <code>node web/scripts/upgrade-2022-01.mjs --verify-fixture</code>. В ней same-version путь обязан пропустить fetch, а mismatch — остаться видимым до mutation.',
'<strong>Действие.</strong> Сделайте snapshot единственным входом для initial state при совпадении. Для mismatch создайте явно названный recovery path и отдельный test, а не fallback в общий hook.',
'<strong>Результат.</strong> У каждого значения появляется owner и время жизни; второй fetch становится осознанным переходом, а не побочным эффектом mount.',
]),
heading('Граница модели и реализации'),
paragraph('Документация React исторического периода говорит о существующей разметке, присоединении обработчиков и требовании идентичного rendered content. Она не описывает JSON shape из этой статьи, не знает поля <code>entry-r7</code> и не выбирает fetch policy. Fixture не импортирует React, Next.js или DOM API; её action labels — учебные названия, а не trace framework runtime. Поэтому она пригодна для проверки причинной модели, но не для проверки конкретного renderer.'),
paragraph('Project decision начинается там, где появляются реальные inputs: идентичность пользователя, cookie, locale, feature flags, route params, permission set, cache-control и время формирования документа. Не нужно складывать их в одну длинную строку version без объяснения. Лучше определить, какие из них действительно меняют HTML, кто формирует marker и где client bootstrap обязан сравнить его со snapshot. Этот контракт сложнее, чем один boolean, зато он не прячет ответственного за рассинхронизацию.'),
heading('Цена двух крайностей'),
paragraph('Первый крайний вариант — всегда trust snapshot. Он опасен, если version не привязана к реальному source или контексту рендера: пользователь может получить HTML и state из разных условий. Второй — всегда refetch on mount. Он тратит дополнительный переход, создаёт race между snapshot и новым ответом и лишает SSR смысла как источника первого state. Между ними находится проверяемое правило: trust только snapshot с явно совпавшим contract, а mismatches оставлять наблюдаемыми и разбирать до повторной загрузки.'),
paragraph('Это правило не гарантирует отсутствие всех hydration warning и не заменяет тесты markup. Оно даёт понятную границу данных. Когда следующий bug касается разного locale или права доступа, его можно добавить как новую ось contract, а не объяснять задним числом тем, что «SSR иногда ведёт себя иначе». Так развивается система: от одной версии учебной записи к явно описанным входам, проверке и обратимому решению.'),
heading('Историческая граница'),
paragraph('Статья сознательно остаётся в январе 2022 года. Факт о <code>ReactDOM.hydrate</code> и <code>ReactDOMServer.renderToString</code> взят из immutable official documentation snapshot от 23 декабря 2021-го; release record React 17.0.2 датирован мартом 2021-го. Слова <code>hydrateRoot</code>, App Router, React Server Components и правила более поздних версий здесь не используются. Версионный protocol — не исторический API React, а проектная модель, помеченная отдельно.'),
],
commonSources,
);
const fieldArticle = createRevision(
{
slug: 'editorial-2022-01-field-ssr-csr',
title: 'SSR/CSR mismatch: как не выдать второй fetch за исправление',
categories: ['Frontend', 'Производительность'],
cover: '/assets/editorial/2022/ssr-csr-diagnosis-2022.svg',
excerpt: 'Диагностический маршрут для неоднозначного случая: первый экран отдан SSR, client code просит данные снова, а причина может быть в snapshot, версии markup или неверном владельце состояния.',
readingMinutes: 13,
},
[
paragraph('Неоднозначный случай выглядит так: HTML уже показывает запись, но после hydrate код запускает client fetch. Если второй ответ меняет интерфейс, легко объявить это «нормальным обновлением». Проблема в том, что один и тот же симптом покрывает разные причины: snapshot не дошёл, version не совпала, initial state проигнорирован или новый запрос действительно был осознанным действием пользователя. Цена поспешного вывода — закрыть mismatch свежим ответом и потерять цепочку, которую нужно было расследовать.'),
paragraph('Для разбора не нужен выдуманный production incident и не нужна красивая browser trace. Сначала достаточно собрать четыре локальных факта: откуда server взял source, что попало в serialized snapshot, какую версию несёт markup и кто создал client fetch. Затем нужно отделить documentation fact от project decision. React 17 требует согласованного server/client content, но не назначает вашу схему version и не решает, когда именно бизнес-код имеет право обновить запись.'),
heading('Три причины одного повторного запроса'),
paragraph('Первая причина — отсутствующий initial state. Bootstrap получает HTML, но его store начинается пустым, поэтому hook воспринимает страницу как новую и выполняет обычную загрузку. Вторая — mismatch contract: HTML и snapshot появились из разных source version или разного контекста. Третья — явное обновление: пользователь выбрал фильтр, нажал refresh или совершил другое действие, которое по контракту создаёт новую загрузку. Снаружи все три случая похожи на «двойной fetch», но исправления у них разные.'),
dataTable(
'Диагностика повторного client 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'],
],
),
heading('Учебная fixture показывает порядок, а не браузер'),
paragraph('В пакете есть детерминированная in-memory fixture. Она строит один server envelope с <code>entry-r7</code>, затем два плана: matching hydrate и mismatch с ожидаемой версией <code>entry-r8</code>. Никакой реальный запрос не отправляется. JSON нужен только для имитации сериализованного payload, а <code>markupVersion</code> — учебный marker. Это важная граница: fixture не читает DOM и не доказывает, как конкретный React component будет patch markup.'),
codeBlock(diagnosisExample),
paragraph('В mismatch-пути диагностическая запись содержит expected и serialized version. Её outcome прямо называется <code>mismatch-recorded-before-mutation</code>. И markup mutation, и client state mutation остаются <code>not-started</code>; client fetch тоже не выполняется. Такой результат не равен готовому UX. Он только запрещает скрыть неоднозначность внутри общего loading state. После записи фактов команда всё ещё должна выбрать проектный путь восстановления и проверить его отдельно.'),
figure(
'/assets/editorial/2022/ssr-csr-diagnosis-2022.svg',
'Диагностическая схема: повторный client fetch ветвится на отсутствие initial snapshot, совпавшую версию с игнорированным snapshot, mismatch версий и явное пользовательское обновление. Для mismatch показана остановка до mutation, сохранение двух версий и выбор отдельного recovery path.',
'Диаграмма не выдаёт все повторные запросы за ошибку. Она требует прежде назвать trigger, owner и version, а затем выбрать действие для конкретной ветки.',
),
heading('Что записать до изменения кода'),
paragraph('Начните с карточки одного document response. В ней нужны route input, identity контекста, source version, markup marker, serialized version, имя bootstrap-функции и условие client fetch. Не подставляйте в карточку выдуманный timestamp, LCP или сетевой waterfall: если их не собирали, они не помогают отличить причины. Достаточно фактов, которые можно получить из собственного server response и initial-state boundary.'),
paragraph('Затем проверьте порядок. Сравнение version обязано происходить раньше действия, которое создаёт новое состояние. Если current code делает наоборот — сначала mount запускает fetch, потом effect читает snapshot — речь не о тонком cache tuning. Нарушена граница: client уже получил право изменить state до того, как понял, что сервер передал. Исправление должно перенести owner check выше, а не только добавить условие на второй ответ.'),
heading('Маршрут: симптом → причина → проверка → действие'),
orderedList([
'<strong>Симптом.</strong> Первый HTML есть, но сразу после hydrate начинается client fetch или содержимое меняется без действия пользователя.',
'<strong>Причина.</strong> Возможны как минимум четыре ветки из таблицы; по одному network name нельзя выбрать ни одну из них.',
'<strong>Проверка.</strong> Сохраните route input, source version, markup marker, serialized version и trigger fetch. Если marker отсутствует, это отдельный дефект контракта, а не доказательство «устаревших данных».',
'<strong>Проверка порядка.</strong> Сравните версию до создания client state mutation. Для учебного mismatch fixture возвращает <code>not-started</code> и не прячет его новым fetch.',
'<strong>Действие для отсутствующего snapshot.</strong> Передайте initial state явно и поставьте guard на повторный запрос при accepted snapshot.',
'<strong>Действие для mismatch.</strong> Оставьте diagnostic visible, назовите recovery owner и покрыть выбранный путь отдельным test. Не называйте автоматическое обновление «исправлением», пока причина расхождения не определена.',
'<strong>Действие для явного refresh.</strong> Свяжите fetch с пользовательским trigger и новым input; тогда он не смешивается с hydrate.',
]),
heading('Проверка самой модели'),
paragraph('Команда <code>node web/scripts/upgrade-2022-01.mjs --verify-fixture</code> проверяет двенадцать assertions. Среди них есть equal-version путь без второго fetch, запись mismatch до mutation, разделение phase/action для source, serialized snapshot, hydrate и client fetch, а также запрет выдавать схему за реализацию React или Next.js. Проверка не обращается к сети и не читает файлы. Она полезна как регрессия смысла: после рефакторинга labels и порядок переходов нельзя поменять молча.'),
paragraph('Эта проверка не заменяет browser test. Реальная страница может иметь HTML mismatch из-за locale, timezone, cookie, permissions, nondeterministic data, feature flag или разного response cache. Для каждого такого input нужно решить: он является частью initial contract, даёт отдельную route variant или требует client-only rendering после согласованного первого прохода. Начните с одного фактора, который меняет visible output. Так диагностика остаётся конкретной, а не превращается в список модных причин.'),
heading('Когда второй fetch действительно уместен'),
paragraph('Повторная загрузка допустима, когда есть новый named trigger и понятный owner: пользователь поменял фильтр, истёк документированный freshness window, подписка принесла новую ревизию или UI вошёл в режим, которого не было в SSR. Даже тогда initial snapshot остаётся историей первого ответа, а client fetch — новой операцией с отдельным version. В диагностике это полезнее, чем общий флаг <code>loading</code>: можно увидеть, какой именно переход изменил state.'),
paragraph('Не стоит лечить случаи без таблицы версий отключением SSR, случайным <code>setTimeout</code> или suppress warning. Такие меры могут убрать видимый переход, но не говорят, какой source сформировал initial HTML. Сначала зафиксируйте contract, потом выберите минимальный обратимый repair. Если в следующем случае окажется, что mismatch создаёт permission layer, у команды уже будет место, куда добавить эту ось, и test, который не даёт скрыть разрыв новым запросом.'),
heading('Историческая граница'),
paragraph('Диагностическая статья ограничена знаниями января 2022 года. Official documentation snapshots от 23 декабря 2021-го описывают <code>ReactDOM.hydrate</code>, <code>ReactDOMServer.renderToString</code> и ожидание одинакового rendered content; release React 17.0.2 опубликован в марте 2021-го. Версионная fixture, её actions и recovery policy — учебная и проектная модель. Поздние React/Next API, production метрики и результаты инцидентов здесь не заявляются.'),
],
commonSources,
);
export const revisions = [practiceArticle, mechanismArticle, fieldArticle].map(({ proseLength, ...revision }) => revision);
export const january2022Revisions = revisions;
function verifyFixture() {
const fixture = runSsrCsrFixture();
const failed = Object.entries(fixture.assertions)
.filter(([, passed]) => passed !== true)
.map(([name]) => name);
if (failed.length > 0) {
console.error('FAIL fixture: ' + failed.join(', '));
process.exitCode = 1;
return;
}
console.log('PASS fixture: ' + Object.keys(fixture.assertions).length + '/' + Object.keys(fixture.assertions).length + ' assertions');
}
if (process.argv.includes('--verify-fixture')) {
verifyFixture();
}
if (process.argv.includes('--print-revisions')) {
console.log(JSON.stringify(revisions));
}