From d884456884b2e247fbebb8d2adcc044d8bc20a3a Mon Sep 17 00:00:00 2001 From: gnezim Date: Mon, 27 Apr 2026 19:20:54 +0300 Subject: [PATCH] Triage spec for SSR hydration mismatch (React #423) 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. --- .../specs/2026-04-27-ssr-hydration-fix.md | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-27-ssr-hydration-fix.md diff --git a/docs/superpowers/specs/2026-04-27-ssr-hydration-fix.md b/docs/superpowers/specs/2026-04-27-ssr-hydration-fix.md new file mode 100644 index 00000000..20587490 --- /dev/null +++ b/docs/superpowers/specs/2026-04-27-ssr-hydration-fix.md @@ -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(); +``` + +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`.