Triage spec for SSR hydration mismatch (React #423)
ci-deploy / build-deploy-test (push) Successful in 1m26s

Prerequisite for re-enabling e2e in ci-deploy. Identifies the new Date()
class as the highest-impact fix and proposes hoisting today/now to the
route loader so SSR and CSR see identical values via _ROUTER_DATA.
This commit is contained in:
2026-04-27 19:20:54 +03:00
parent f5530a971b
commit d884456884
@@ -0,0 +1,118 @@
---
type: spec
status: triage
created: 2026-04-27
---
# SSR ↔ CSR hydration fix
## Symptom
Production deploy at `https://ui-dashboard.gnerim.ru/` throws **React error #423** ("hydration failed; root re-rendered on the client") in the browser console on every page load. The page is functional (React recovers via full client render), but:
- Visible flash of SSR content → client-rendered content during hydration.
- Test failure mode in `tests/e2e/breadcrumbs-parity.spec.ts` and similar data-driven specs (16 tests, see CI/CD run 527): the SSR-rendered breadcrumb is missing the section item that the client would render, so polling the DOM never reaches the expected shape within the 15s timeout.
- E2e suite is currently **disabled in `ci-deploy.yml`** (see commit `77cf87d`) pending these fixes.
## Cause
Components and shared utilities call `new Date()` / `window.matchMedia()` / `sessionStorage` **inside the render path**. SSR (no `window`, container TZ, server clock) and CSR (browser TZ, browser clock, real DOM/storage) diverge → React detects mismatch → throws #423 → falls back to full client render.
## Hot spots (in order of impact)
### 1. `new Date()` during render — highest impact
Every render produces a different timestamp. When SSR renders at T₀ and the browser hydrates at T₀ + Δ, the embedded date string can change. At day boundaries the date *string* (e.g. `20260427` vs `20260428`) literally differs.
| File | Line | Notes |
|---|---|---|
| `src/features/online-board/components/OnlineBoardStartPage.tsx` | 77-79, 100, 123, 141 | `todayYyyymmdd()`, `currentWeekBoundsYyyymmdd()`, `nextWeekBoundsYyyymmdd()` all call `new Date()` and embed results in popular-request links. |
| `src/features/schedule/dateLabels.ts` | 26-32 | `today = new Date()` drives "Текущая неделя" label. |
| `src/features/flights-map/components/FlightsMapStartPage.tsx` | 51, 62 | `now = new Date()` for the date filter. |
| `src/ui/calendar/DayQuickPick.tsx` | 50 | `today = new Date()` highlights today's pill. |
### 2. `useIsMobileViewport` — viewport-narrow only
`src/shared/hooks/useIsMobileViewport.ts:13-17``useState` initializer reads `window.matchMedia()` on the client but returns `false` on SSR. Mismatch only happens on viewport ≤ 640px. Used by `OnlineBoardStartPage:169` and others.
### 3. `useSearchHistory` — only on returning visits
`src/shared/hooks/useSearchHistory.ts:138-140` — initial state from `sessionStore.get(...)` (localStorage). Returns `[]` on SSR; on client returns saved searches. First-visit users see no mismatch; returning users see one once they have history.
### 4. `Intl.DateTimeFormat` with locale-dependent output
`src/features/online-board/components/DayTabs/DayTabButton.tsx:19`, `DaySelect.tsx:21`, `src/features/schedule/components/DayGroupedFlightList.tsx:293-294`. If the input `Date` is correct, output should match. **Only mismatches when fed a `new Date()` from category 1.**
### 5. `typeof window` ternaries returning different strings
`src/ui/flights/FlightCard.tsx:675`, `src/features/online-board/components/BoardDetailsHeader/LastUpdate.tsx:37`, `FlightActions.tsx:52``shareUrl = window.location.href`. Empty on SSR, real URL on client. Anywhere this string is rendered into HTML or attributes, mismatch.
## Fix order
Smallest blast radius first; each step independently shippable.
### Step 1 — Hoist `new Date()` to the SSR loader
For `OnlineBoardStartPage`, `FlightsMapStartPage`, `ScheduleSearchPage`: compute "today" once on the server inside the route's `loader` (Modern.js exposes one), pass through `useLoaderData()`, use that value in render. SSR and CSR see the same string because client gets the server's value via `_ROUTER_DATA` payload.
```ts
// Route loader (server-only)
export const loader = (): { today: string } => ({
today: todayYyyymmdd(),
});
// Component
const { today } = useLoaderData<typeof loader>();
```
Touches ~3 files. Removes the date class of mismatches entirely.
### Step 2 — `useIsMobileViewport` SSR-stable initializer
```ts
const [isMobile, setIsMobile] = useState(false); // deterministic
useEffect(() => {
setIsMobile(window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT_PX}px)`).matches);
// ...
}, []);
```
First paint always desktop layout, flips to mobile post-hydration. Causes a one-frame layout swap on narrow viewports. Acceptable trade-off in the React SSR ecosystem.
### Step 3 — `useSearchHistory` deferred read
Same shape: initialize `[]`, populate via `useEffect`. Returning visitors see history appear post-hydration. Existing `SEARCH_HISTORY_CHANGED_EVENT` handler stays unchanged.
### Step 4 — `shareUrl` from typeof-window ternaries
Either:
- Don't render share URLs server-side at all (only show the share button after `useEffect` mounts), or
- Pass `request.url` from the SSR loader (Modern.js's `request` context already includes it) so SSR has the same `shareUrl` as the client would compute.
### Step 5 — Re-enable e2e in `ci-deploy`
Uncomment the Playwright steps in `.gitea/workflows/ci-deploy.yml`. Run a few CI rounds; the breadcrumb-parity / time-filter / day-tabs failures from CI run 527 should disappear because the hydration root cause is gone.
## Verification
**Per-step:**
1. Build the SSR container locally (`pnpm build:standalone && docker build -f Dockerfile.react -t flights-web:dev .`).
2. Run it: `docker run --rm -p 3002:8080 -e API_BASE_URL=http://localhost:3002/api -e MAP_TILE_URL=...`.
3. Open in browser, watch console. After each step, the corresponding mismatch should be gone (and React #423 occurrences should drop).
4. Final: zero #423 errors → re-enable e2e → CI run should pass without the 16 failing specs from run 527.
**Non-regression:**
- `pnpm test` (vitest) — should remain 2044/2044 green; the SSR-loader changes are server-only and tests run client-side.
- Manual smoke on narrow + wide viewport, and a refresh on a page with saved search history (Step 3).
## Out of scope
- The breadcrumb URL canonicalization (`/ru``/ru-ru` redirect) is a related but separable issue — once hydration is stable, audit whether the breadcrumb component generates the canonical form.
- WAF rate-limit (already mitigated via nginx `proxy_cache` + `/api/dictionary` 6h TTL + pre-warm step in CI).
## References
- React docs error decoder: https://reactjs.org/docs/error-decoder.html?invariant=423
- Original CI/CD pipeline spec: `docs/superpowers/specs/2026-04-25-cicd-pipeline-design.md`
- Failing CI run baseline: Gitea Actions run 527 on `chore/tim-tunnel-routing` (16 failed assertions, all data-driven specs).
- Verified-working green run after disabling e2e: run 528 on `chore/tim-tunnel-routing`.