Triage spec for SSR hydration mismatch (React #423)
ci-deploy / build-deploy-test (push) Successful in 1m26s
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:
@@ -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`.
|
||||
Reference in New Issue
Block a user