Skip to content

fix(web): премахване на hydration несъответствието на всяка страница (#274) - #300

Open
DiyanaDimitrova wants to merge 5 commits into
midt-bg:mainfrom
DiyanaDimitrova:fix/hydration-mismatch
Open

fix(web): премахване на hydration несъответствието на всяка страница (#274)#300
DiyanaDimitrova wants to merge 5 commits into
midt-bg:mainfrom
DiyanaDimitrova:fix/hydration-mismatch

Conversation

@DiyanaDimitrova

@DiyanaDimitrova DiyanaDimitrova commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Затваря #274.

Две независими hydration несъответствия по атрибути се задействаха на всяка страница (шумна конзола, която маскира истински предупреждения). И двете са адресирани в apps/web/app/root.tsx, плюс регресионен предпазител.

1. Nonce върху <link> за стиловете (грешката в приложението)

<ServerRouter nonce> задава nonce-а в FrameworkContext на React Router (нужен за streaming <script> парчетата), а <Links> слага същия този nonce върху всеки <link>, който рендира. Клиентският <HydratedRouter> никога не получава nonce-а (той не се сериализира в hydration handoff-а), затова на клиента <Links> рендира линковете без nonce → на сървъра nonce="…" срещу nonce={undefined} на клиента, на всяка страница.

Поправка: подаваме изричен празен nonce на <Links> — изрична non-null стойност печели пред контекстния nonce (RR пада обратно към контекстната стойност само когато prop-ът е null), така че сървърът и клиентът рендират nonce="" и съвпадат. Линковете никога не са имали нужда от истински nonce: CSP е style-src 'self' 'unsafe-inline' (без nonce за стилове). Скриптовете запазват истинския си per-request nonce през <Scripts nonce> / renderToReadableStream, така че CSP гейтът за скриптове остава непроменен.

2. Шум по <body> от разширения

Браузър разширения (ColorZilla cz-shortcut-listen, Grammarly data-gr-*, мениджъри на пароли) инжектират атрибути върху <body> преди React да хидратира. Добавен suppressHydrationWarning върху <body> — документираният escape hatch на React. Той е плитък (заглушава несъответствия само по собствените атрибути/текст на <body>, никога по наследниците), а приложението не задава никакви атрибути на <body>, така че нито едно истинско несъответствие не се маскира.

3. Регресионен предпазител

apps/web/app/root.render.test.ts — фокусиран AST предпазител върху експортирания Layout, който твърди, че <Links> носи изричен празен nonce, а <body> носи suppressHydrationWarning. Достоверен SSR тест би изисквал крехки вътрешности на React Router; този хваща точната регресия (проверено — пада, ако някой от двата prop-а бъде върнат) без това обвързване.

Проверка

  • Възпроизведено оригиналното предупреждение с headless Chrome (CDP): налично на /, /contracts, /companies, /authorities, /methodology; изчезва след поправката; SSR емитва nonce="" върху линковете за стилове, докато скриптовете запазват истинския nonce.
  • pnpm typecheck · pnpm lint · @sigma/web 484/484.

…inks (midt-bg#274)

React Router seeds its FrameworkContext nonce from `<ServerRouter nonce>` (needed for
the streaming `<script>` chunks), and `<Links>` stamps that nonce on every `<link>` it
renders. The client's `<HydratedRouter>` never receives the nonce — it isn't serialized
into the hydration handoff — so on the client `<Links>` renders the same links WITHOUT a
nonce. The result is a server `nonce="…"` vs client `nonce={undefined}` attribute
mismatch on every page: React warns and leaves that subtree in its server state.

Pass an explicit empty nonce to `<Links>` so server and client both render `nonce=""`
and agree. The stylesheet/icon links never needed a real nonce (CSP is
`style-src 'self' 'unsafe-inline'`); scripts keep their real nonce via `<Scripts>` /
renderToReadableStream, so the CSP script gate is unchanged.

Verified with headless Chrome (CDP console capture): the hydration warning is gone on
/, /contracts, /companies, /authorities and /methodology; scripts still carry the
per-request nonce.
…bg#274)

After the stylesheet-nonce fix, the remaining per-page hydration warning comes from
browser extensions (ColorZilla's `cz-shortcut-listen`, Grammarly's `data-gr-*`, password
managers, …) that inject attributes onto <body> before React hydrates — React then reports
a server≠client attribute mismatch. The app sets no attributes on <body>, so the source is
purely the visitor's extensions.

Add `suppressHydrationWarning` to <body> — React's documented escape hatch for third-party
DOM mutations. It's shallow: it silences mismatches on <body>'s own attributes/text only,
never its children, so a genuine app-level mismatch inside the page still surfaces. This
clears the last of the console noise midt-bg#274 set out to remove so real mismatches aren't masked.
The review's one actionable note was a missing regression test. A faithful SSR test
would need React Router internals (a manifest + data-router state) to reproduce the
server/client FrameworkContext nonce split, which is brittle and couples to RR internals.
Instead, add a focused AST guard over the exported `Layout`: assert `<Links>` carries an
explicit empty `nonce` and `<body>` carries `suppressHydrationWarning`. It fails if either
prop is reverted (verified), catching the exact regression without SSR scaffolding.

Reads root.tsx via Vite's `?raw` import (typed by vite/client) rather than node:fs, since
apps/web test files typecheck under the Workers config which has no Node types.
@DiyanaDimitrova DiyanaDimitrova changed the title fix(web): eliminate per-page hydration mismatch (#274) fix(web): премахване на hydration несъответствието на всяка страница (#274) Aug 11, 2026
@DiyanaDimitrova
DiyanaDimitrova marked this pull request as draft August 11, 2026 05:51
@DiyanaDimitrova
DiyanaDimitrova marked this pull request as ready for review August 12, 2026 12:23
@ydimitrof

Copy link
Copy Markdown
Contributor

Review — verdict: APPROVE (9.5/10)

Security-critical scan: CLEAN

No secrets, no new URLs (only pre-existing same-origin asset paths), no dependency changes, no obfuscation. semgrep, check, test, cacbg all pass (coverage-comment skipped).

The one security-adjacent change — RR-rendered <link>s no longer carry the CSP nonce — is inert, verified end-to-end:

  • style-src 'self' 'unsafe-inline' has no nonce-source (apps/web/app/lib/security.ts:13), so the nonce was never load-bearing for styles and 'unsafe-inline' is not nullified.
  • The script gate is untouched: entry.server.tsx:17,29,32 still feeds the real per-request nonce to <ServerRouter>, renderToReadableStream, <Scripts>, <ScrollRestoration>.
  • The worker's cache-safe hash CSP is unaffected — hashTrustedInlineScripts matches inline <script> only (workers/csp.ts, INLINE_SCRIPT_RE).

Correctness — verified against sources, not just the PR description

  • Mechanism confirmed. react-router 7.18.0 Links does if (nonce == null && contextNonce) nonce = contextNonce — an explicit "" is non-null, so it wins over the FrameworkContext nonce. That nonce is seeded by ServerRouter but is not part of the window.__reactRouterContext handoff, so the client genuinely renders no nonce. The diagnosis is accurate.
  • Scope confirmed complete. React Router already stamps suppressHydrationWarning on every element <Scripts> emits (its inline scripts and all rel="modulepreload" links) — which is precisely why only <Links>'s stylesheet/icon links surfaced the mismatch, and why <Scripts nonce> / <ScrollRestoration nonce> correctly need no equivalent change. Nothing is left half-fixed.
  • <body suppressHydrationWarning> rationale checked. The one first-party thing that mutates <body> classes from localStorage (the ИО АД widget's getSettings()) is started from useEffect in AccessibilityWidget.tsx:113 — i.e. after hydration. So "the app itself sets no attributes on <body>" holds at hydration time, and the flag being shallow keeps genuine in-page mismatches visible.

The regression guard does fail if either prop is reverted (nonce absent → undefined !== ''; attribute absent → toBeDefined() fails). typescript is a declared devDependency of @sigma/web, the test matches app/**/*.test.ts under environment: 'node', and ?raw is typed via vite/client in tsconfig.cloudflare.json — all consistent. The suite was not re-run locally; this relies on the CI test job.

Non-blocking nits

  1. root.render.test.ts:59,65expect(layout).toBeDefined() sits after layout! / layoutElements(...)[0]! have already been dereferenced. If Layout is ever renamed or converted to a const arrow, the test fails with a TypeError rather than that assertion. Move the guard to the top of each test, or into a beforeAll.
  2. The guard asserts syntactic form: a semantically identical nonce={''} or suppressHydrationWarning={true} would fail it. Fine for a pinned guard — worth one line in the file header so a future maintainer doesn't conclude they broke the fix.
  3. Latent coupling worth a cross-reference: if style-src ever gains a nonce-source (ADR-0004 explicitly contemplates tightening), <Links nonce=""> would also strip the nonce from React Router's critical-CSS <style> element. Real risk is low — criticalCss is a dev-mode Vite feature and CSP is production-only — but a one-liner next to security.ts:13 or in ADR-0004 would close the loop.

Summary

Atomic, correctly diagnosed, minimally scoped, comment density matches the repo's house style, and the trade-off (a structural guard instead of a brittle React Router-internals SSR test) is stated honestly rather than papered over. The nits above are polish, not merge blockers.

@nikimilenkov nikimilenkov left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Обстоен преглед — PR #300 @ ebfd417 (затваря #274)

Благодаря за този PR — диагнозата е точна, механизмът е проверен от мен до ниво изходен код на React/React Router, и поправката е правилната сред наличните варианти. Прегледът мина по строгия протокол (пет паралелни измерения, всяка находка проследена и възпроизведена върху чисто копие на този HEAD); резултатът е одобрение — бележките по-долу са за прецизност на коментарите и за дълготрайност на workaround-а, не за коректност.

Предложение: одобрение — 0 критични, 0 високи, 3 средни (само документация/поддръжка), 5 ниски. (Съветодателно — решението е на поддържащите.)

Прегледът се разминава с този на @ydimitrof само в детайли и стига до същия извод — присъединявам се към одобрението. Две дребни уточнения към неговите формулировки, установени при проверката на източниците: (1) „stylesheet/icon links surfaced the mismatch“ — предупреждаваха само stylesheet линковете; иконите са React 19 hoistables и се хидратират чрез съпоставяне по href/rel, при което nonce изобщо не се сравнява (потвърдено и от наблюдаваната на живо конзола — икони не фигурират); (2) „criticalCss is a dev-mode Vite feature“ — unstable_getCriticalCss е build hook, не само dev, така че съображението за свързаността със style-src важи и в продукционна посока (вж. СРЕДНА 3).

Изпълнени проверки: @sigma/web 484/484 (потвърдено тук, 44 файла); мутации върху двата prop-а — маха се nonce="" → тестът пада ✓, nonce={undefined} → пада ✓, маха се suppressHydrationWarning → пада ✓; CI зелен (check/semgrep/test/cacbg); сканът за сигурност на diff-а е чист (без тайни/URL-и/зависимости). Механизмът е потвърден в инсталираните пакети, не по памет: RR 7.18.0 if (nonce == null && contextNonce) — изричното "" не е == null и печели; React 19 сериализира nonce="" буквално (проверено с изпълнен renderToStaticMarkup); клиентският FrameworkContext на HydratedRouter наистина няма ключ nonce. Отделно потвърждение от практиката: същото предупреждение наблюдавах на живо върху сроден deployment — листваше точно и само трите rel="stylesheet" линка.

Какво е проверено и вярно

  • nonce="" е единствената in-tree стойност, която работи. Алтернативата <Links nonce={nonce} /> (контекстът на приложението) би пресъздала несъответствието: useNonce() е undefined на клиента (няма entry.client.tsx, няма provider), а undefined също задейства fallback-а. Проверено и че двете „очевидни" алтернативи са архитектурно погрешни: сериализиране на nonce през root loader-а counter-ира edge-cache CSP дизайна (workers/app.ts:87-98 — замразен кеш не бива да преиграва nonce), а махането на nonce от <ServerRouter> убива streaming скриптовете под строгия CSP.
  • CSP повърхността е чиста. Изброено е всичко, което <Links> рендира в това приложение (2 stylesheet + 4 icon линка; без page-prefetch, без criticalCss — проверено в конфигурацията): stylesheet-ите минават през 'self', иконите през img-src 'self'; style-src 'self' 'unsafe-inline' (security.ts:13) няма nonce източник, така че нищо не се неутрализира. Нищо не чете nonce от DOM (grep: 0). Реалният nonce остава само на скриптовете, които го изискват — маргинално по-малко nonce ехо в маркъпа.
  • „Скриптовете не предупреждават" е вярно, с доказан механизъм: RR слага suppressHydrationWarning: true на всеки елемент, който <Scripts>/<ScrollRestoration> издават (вкл. modulepreload линковете) — структурно невъзможно е да предупредят, затова е правилно, че PR-ът не ги пипа.
  • suppressHydrationWarning е плитък — потвърдено в react-dom: гейтва само собствените атрибути/директен текст на елемента, никога наследниците. Приложението не поставя атрибути на <body> преди хидратацията (двете места, които го пипат — a11y widget-ът с classList.toggle и SiteHeader с overflow — са в useEffect, т.е. след нея).
  • Тестът убива и трите реални регресии (мутационно проверено); ?raw импортът и мотивът за него (Workers tsconfig без Node типове) са точни; typescript е съществуваща devDependency и не може да стигне до клиентския bundle (нула импортьори извън vitest include-а); добавената цена е ~200 ms в един test worker. Никаква runtime цена.

Средни бележки (документация/поддръжка — нищо блокиращо)

  1. Прецизност на двата коментара в root.tsx — три твърдения са по-широки от фактите, а бъдещ читател ще действа по тях.
    (а) „стampва го на всеки <link>… mismatch на ВСЯКА страница" — предупреждават само rel="stylesheet" линковете: иконите са React 19 hoistables, които се хидратират чрез съпоставяне по href/rel (nonce никога не се сравнява там). Съвпада и с наблюдаваното на живо — в конзолата фигурират точно stylesheet линковете.
    (б) „приложението не задава никакви атрибути на <body>" — буквално невярно: accessibility.js:106,116 (classList.toggle) и SiteHeader.tsx:60 го пипат; вярното и важното е „преди хидратацията" — и то държи само защото widget-ът се инициализира от useEffect. Заслужава си коментарът да каже точно това, защото при промяна на тайминга (напр. изнасяне на getSettings() в pre-hydration inline скрипт срещу FOUC) подтискането ще замаскира точно този клас разминаване.
    (в) Никъде не е казано, че това е само-development проблем: продукционният react-dom изобщо не съдържа диффинг на атрибути при хидратация (проверено — 0 срещания). Изречение „коригираме dev-конзолата, не продукционен дефект" би спестило погрешния извод.
    (Дребно: „RR falls back only when the prop is null" — реалната проверка е == null, т.е. и undefined; формулировката леко подвежда.)
  2. Произходът на бъга не е записан, значи workaround-ът няма условие за премахване. Това е регресия от react-router 7.18.0 — CHANGELOG, #15170 „Use the ServerRouter nonce for nonce-aware SSR components…": преди този bump <Links> не четеше контекстния nonce и несъответствието не е съществувало. Едно изречение в коментара (версия + upstream PR) прави workaround-а премахваем при следващ bump. Отделно — <Links> липсва от собствения RR документ docs/how-to/security.md, а fallback-ът е приложен върху клас елементи, които React хидратира чрез диффинг: легитимен upstream доклад.
  3. Инертността на nonce="" виси на две неизпълними-от-теста условия, невързани към нищо. (а) style-src да пази 'self' — пазено от security.test.ts:11, но без обратна препратка от root.tsx; (б) празният nonce тече през цялото поддърво на <Links> — criticalCss <style> и PrefetchPageLinks modulepreload-ите — днес мъртви пътища (проверено: нито page дескриптори, нито criticalCss конфигурация), но при бъдещо включване биха се рендирали с празен nonce тихо. Едно изречение за обхвата в коментара + четириредовият тест, който заковава „<body> няма други атрибути освен подтискането" (AST помощниците в новия файл го правят тривиален), затварят и двете посоки.

Ниски бележки

  1. Тест-ергономика: (а) семантично еквивалентното suppressHydrationWarning={true} проваля теста (свръх-строг към формата); (б) преместване на <Links nonce="" /> в дъщерен компонент пада с TypeError вместо със смислено съобщение — non-null дереференциите на ред 43-44/57/66 се изпълняват преди expect(layout).toBeDefined() на 59/71, така че „приятелските" assertions са мъртви; (в) тестът пази синтаксис, не поведение — RR bump, който промени fallback семантиката (реалният регресионен вектор), минава през него. Repo-то вече има jsdom + createRoutesStub образец (conflicts.render.test.tsx), с който може да се твърди link.getAttribute('nonce') === '' върху рендиран изход — по-издръжливо на рефакторинг, макар че и то не възпроизвежда истинския SSR/client split (както коментарът в теста честно признава).
  2. <html lang="bg"> остава извън обхвата — същите разширения инжектират и върху документния елемент (а и a11y widget-ът пише html.classList, макар след хидратацията). В наблюдаваното предупреждение html не фигурира, така че това е въпрос за изрично решение („в обхвата на #274 ли е чистата конзола за посетители с такива разширения?"), не дефект.
  3. Двойният nonce канал вече е излишен: от 7.18.0 <Scripts>/<ScrollRestoration> сами падат към контекстния nonce (проверено на ред 10040/10748 в dist-а), така че подаването през useNonce() дублира; с новото литерално "" в root.tsx съжителстват три различни nonce идиома в четири съседни реда. Кандидат за отделен, малък консолидиращ PR — не за този.
  4. Едноредова обратна препратка в security.ts:5-13 („<Links nonce=""> в root.tsx разчита 'self' да оторизира stylesheet-ите") би затворила връзката и от другата страна.
  5. Извън обхвата на diff-а, само за отбелязване: hardenResponse (workers/app.ts:89) буферира целия HTML с await response.text() по cache-MISS пътя — предсъществуващо, но е гръбнакът, на който стъпва целият CSP аргумент тук.

Какво беше проверено (чеклист)

  • Скан за сигурност: чисто (без тайни/URL-и/зависимости; тестът е чист AST parse без изпълнение на код)
  • 484/484 потвърдено тук; трите реални мутации падат, двете false-fail форми документирани
  • Fallback семантиката, сериализацията на nonce="", липсата на client nonce и подтискането при <Scripts> — проверени в инсталираните react-dom/react-router източници, не по документация
  • CSP повърхността изброена елемент по елемент срещу реалната политика; edge-cache CSP пътят недокоснат
  • Продукционният bundle няма атрибутен хидратационен диффинг (grep: 0) — dev-only характер потвърден
  • Твърденията от описанието на PR-а сверени: CSP низът точен, 484/484 точно, „без body атрибути" — вярно по същество за прозореца на хидратация (двете post-hydration изключения намерени и отчетени)
  • Регресионният произход установен: RR 7.18.0 / #15170 (CHANGELOG)

Здравна оценка: 9 / 11

Коректност ✓ · Сигурност ✓ · Производителност ✓ · Тестове ◐ (убива реалните регресии; false-fail форми и синтактичен, не поведенчески пазач) · Коментари/документация ◐ (три твърдения по-широки от фактите; липсващ произход) · Обхват ✓ · Идемпотентност ✓ · Наблюдаемост ✓ · Стил ✓ · Поддръжка ◐ (незаписани условия за премахване/съвместимост) · Честност на валидацията ✓.

Преди сливане (по преценка — нищо не блокира)

  1. Прецизиране на двата коментара (СРЕДНА 1) и ред за произхода RR 7.18.0/#15170 (СРЕДНА 2) — чисто текстови промени.
  2. Четириредовият тест за „<body> без други атрибути" + изречение за обхвата на <Links nonce=""> (СРЕДНА 3).
  3. Ниските — по желание; 3 (консолидацията на nonce каналите) е за отделен PR.

Хубава, дисциплинирана поправка: правилният лост, на правилното място, с честно описани ограничения на теста — и с доказуем произход в upstream, който си заслужава да бъде докладван там. Благодаря!

…scope

Address the non-blocking review notes on PR midt-bg#300:

- root.tsx <Links> comment: only the stylesheet links surface the mismatch (icon
  links are React 19 hoistables matched by href/rel, where nonce isn't compared);
  it's a dev-console warning, not a production defect (prod react-dom does no
  attribute hydration diffing); the RR fallback is `== null` (null/undefined);
  record the origin (react-router 7.18.0, remix-run/react-router#15170) so the
  workaround is removable at a future bump; note the empty nonce flows through the
  whole <Links> subtree (criticalCss <style> / PrefetchPageLinks modulepreloads).
- root.tsx <body> comment: correct "no body attributes" -> "no body attributes
  before hydration" (accessibility.js + SiteHeader mutate it from useEffect), and
  note the flag would mask a pre-hydration body mismatch.
- security.ts: back-reference that <Links nonce=""> relies on style-src keeping
  'self' with no nonce source.
- root.render.test.ts: add a guard that <body> has exactly one attribute
  (suppressHydrationWarning); reorder so toBeDefined() runs before the derefs.

No runtime change. typecheck + lint + @sigma/web 485/485.

@nikimilenkov nikimilenkov left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Благодаря за прецизния follow-up, @DiyanaDimitrova — проверих 515dba0 точка по точка срещу прегледа си, със същата дисциплина: пуснах пакета и мутирах новия тест върху този HEAD. Всичко е адресирано; потвърждавам одобрението (съветодателно, решението е на поддържащите).

Проверено на този HEAD

  • СРЕДНА 1 (прецизност на коментарите) — и трите твърдения са коригирани точно: „only the stylesheet links surface this mismatch" с назован механизъм (иконите са hoistables, съпоставяни по href/rel); „the app sets no <body> attributes before hydration" с изричното предупреждение, че pre-hydration inline скрипт би бил маскиран; „development-console warning… production react-dom does no attribute hydration diffing". И формулировката „null/undefined" за fallback-а е точна.
  • СРЕДНА 2 (произход) — записана: RR 7.18.0 / remix-run/react-router#15170, с условие за премахване („revisit/remove … if a future RR release changes that behaviour"). Точно това прави workaround-а премахваем при следващ bump.
  • СРЕДНА 3 (свързаност) — затворена от двете страни: обратната препратка в security.ts („<Links nonce=""> relies on style-src retaining 'self'…") + документираният обхват върху цялото <Links> поддърво (criticalCss / PrefetchPageLinks) + новият трети тест, който заковава <body> към точно един атрибут. Мутирах го за проверка: инжектиран className върху <body> → тестът пада с точното съобщение. Това е и предпазителят, който прави подтискането дългосрочно безопасно.
  • НИСКА (ред на assertions) — поправена: expect(layout).toBeDefined() и новите пазачи за links[0]/bodies[0] вече стоят преди дереференциите, така че липсващ/преименуван Layout пада със смислено съобщение, не с TypeError.
  • Пакетът на този HEAD: 485/485 (пуснат тук; новият тест е третият в root.render.test.ts).

Останалите незадължителни бележки (строгостта към формата {true}, jsdom вариантът на пазача, консолидацията на nonce каналите след 7.18.0) са си по преценка — нито една не е блокираща, а последната така или иначе е за отделен PR.

Чиста, дисциплинирана поправка с ясно записан произход и премахваемост — точно както трябва да изглежда workaround за upstream регресия. Благодаря!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants