---
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`.
