diff --git a/src/features/flights-map/components/FlightsMapStartPage.tsx b/src/features/flights-map/components/FlightsMapStartPage.tsx index ab2f862c..9887d0a1 100644 --- a/src/features/flights-map/components/FlightsMapStartPage.tsx +++ b/src/features/flights-map/components/FlightsMapStartPage.tsx @@ -9,7 +9,7 @@ * @module */ -import { type FC, lazy, Suspense, useState, useEffect, useCallback, useMemo } from "react"; +import { type FC, lazy, Suspense, useState, useEffect, useCallback, useMemo, useRef } from "react"; import { useLocale } from "@/i18n/useLocale.js"; import { useTranslation } from "@/i18n/provider.js"; import { PageLayout } from "@/ui/layout/PageLayout.js"; @@ -37,6 +37,7 @@ import type { IMapPopup, IFlightRoute, } from "../types.js"; +import { todayYyyymmdd } from "@/shared/utils/datetime/index.js"; import "./FlightsMapStartPage.scss"; const MapCanvas = lazy(() => @@ -47,13 +48,9 @@ const MapCanvas = lazy(() => // Date helpers // --------------------------------------------------------------------------- -function todayYyyymmdd(): string { - const now = new Date(); - const y = now.getFullYear().toString(); - const m = (now.getMonth() + 1).toString().padStart(2, "0"); - const d = now.getDate().toString().padStart(2, "0"); - return `${y}${m}${d}`; -} +// todayYyyymmdd lives in @/shared/utils/datetime — imported below — so the +// SSR loader and the client component agree on a single value carried via +// _ROUTER_DATA. The local helper here only handles month arithmetic. function addMonthsYyyymmdd(base: string, months: number): string { const y = Number(base.slice(0, 4)); @@ -79,11 +76,24 @@ export interface FlightsMapStartPageProps { * fetch tiles from an upstream tile service (e.g. flights.test.aeroflot.ru). */ tileUrl?: string; + /** + * yyyyMMdd of "today" computed once on the SSR server (route loader) + * and passed in. See OnlineBoardFilterProps.today — same purpose: + * eliminate `new Date()` calls during render that diverge between SSR + * and CSR and trigger React hydration error #423. + */ + today?: string; } export const FlightsMapStartPage: FC = ({ tileUrl: tileUrlProp, + today, }) => { + // Capture a single fallback today for unit tests that don't wire the + // route loader. Production always supplies the prop; the ref is unused + // there but always called to keep hook order stable. + const fallbackTodayYmd = useRef(todayYyyymmdd()).current; + const todayYmd = today ?? fallbackTodayYmd; const { t } = useTranslation(); const { language } = useLocale(); @@ -145,28 +155,26 @@ export const FlightsMapStartPage: FC = ({ const searchParams = useMemo(() => { if (!filterState.departure) return null; - const today = todayYyyymmdd(); return { departure: filterState.departure, arrival: filterState.arrival, - dateFrom: today, - dateTo: addMonthsYyyymmdd(today, 6), + dateFrom: todayYmd, + dateTo: addMonthsYyyymmdd(todayYmd, 6), connections: effectiveConnections, }; - }, [filterState.departure, filterState.arrival, effectiveConnections]); + }, [filterState.departure, filterState.arrival, effectiveConnections, todayYmd]); // Build calendar params const calendarParams = useMemo(() => { if (!filterState.departure) return null; - const today = todayYyyymmdd(); return { - date: today, + date: todayYmd, departure: filterState.departure, arrival: filterState.arrival, connections: filterState.connections, }; - }, [filterState.departure, filterState.arrival, filterState.connections]); + }, [filterState.departure, filterState.arrival, filterState.connections, todayYmd]); const { routes, loading, error } = useFlightsMapSearch(searchParams); const { availableDays } = useFlightsMapCalendar(calendarParams); diff --git a/src/features/online-board/components/OnlineBoardFilter.tsx b/src/features/online-board/components/OnlineBoardFilter.tsx index d7ced8c2..a6311a49 100644 --- a/src/features/online-board/components/OnlineBoardFilter.tsx +++ b/src/features/online-board/components/OnlineBoardFilter.tsx @@ -18,6 +18,10 @@ import { CityAutocomplete, SwapCityButton } from "@/ui/city-autocomplete/index.j import { DayQuickPick } from "@/ui/calendar/DayQuickPick.js"; import { useDictionaries } from "@/shared/dictionaries/index.js"; import { useCalendarDays } from "../hooks/useCalendarDays.js"; +import { + todayYyyymmdd, + yyyymmddToIso, +} from "@/shared/utils/datetime/index.js"; import type { CalendarParams } from "../api.js"; import { buildOnlineBoardUrl } from "../url.js"; import { setBoardFilter } from "@/shared/state/crossSectionNavigation.js"; @@ -76,6 +80,14 @@ export interface OnlineBoardFilterProps { initialTimeTo?: string; initialTab?: AccordionTab; initialFlightNumber?: string; + /** + * yyyyMMdd of "today" computed once on the SSR server (route loader) + * and passed through. Eliminates `new Date()` calls during render that + * would diverge between SSR and CSR and trigger React hydration errors. + * Optional with `new Date()` fallback so unit tests don't have to wire + * a route loader. + */ + today?: string; } function hhmmToMinutes(value: string | undefined, fallback: number): number { @@ -94,30 +106,22 @@ function yyyymmddToDate(yyyymmdd: string): Date { } // TZ §4.1.9.3 Table 11/12: board calendar window is -1/+14 days from today. -function getBoardMinDate(): Date { - const d = new Date(); +// Both helpers now take a base Date (parsed from the SSR-stable `today` prop) +// so identical values are computed during SSR and the matching client hydration. +function getBoardMinDate(todayDate: Date): Date { + const d = new Date(todayDate); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() - 1); return d; } -function getBoardMaxDate(): Date { - const d = new Date(); +function getBoardMaxDate(todayDate: Date): Date { + const d = new Date(todayDate); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() + 14); return d; } -/** Today at midnight (yyyy-MM-dd) — used as the base date for calendar - * availability queries so the 31-day bitmask always starts from today-1. */ -function todayIso(): string { - const d = new Date(); - const y = d.getFullYear(); - const m = (d.getMonth() + 1).toString().padStart(2, "0"); - const day = d.getDate().toString().padStart(2, "0"); - return `${y}-${m}-${day}`; -} - /** * Given the list of available yyyyMMdd date strings the API returned, * compute the dates inside [minDate, maxDate] that are NOT available. @@ -152,7 +156,15 @@ export const OnlineBoardFilter: FC = ({ initialTimeTo, initialTab, initialFlightNumber, + today, }) => { + // SSR-stable today: prefer the loader-supplied value; fall back to a + // fresh `new Date()` only when the prop is omitted (unit tests). The + // fallback is captured in a ref so subsequent re-renders don't drift. + const fallbackTodayYmd = useRef(todayYyyymmdd()).current; + const todayYmd = today ?? fallbackTodayYmd; + const todayDate = useMemo(() => yyyymmddToDate(todayYmd), [todayYmd]); + const todayIsoStr = useMemo(() => yyyymmddToIso(todayYmd), [todayYmd]); const { t } = useTranslation(); const navigate = useNavigate(); const { locale, language } = useLocale(); @@ -185,8 +197,11 @@ export const OnlineBoardFilter: FC = ({ hhmmToMinutes(initialTimeTo, 1440), ]); - const boardMinDate = useRef(getBoardMinDate()).current; - const boardMaxDate = useRef(getBoardMaxDate()).current; + // Computed from the SSR-stable todayDate, so SSR and CSR see the same + // boundaries. useMemo over useRef so a date roll-over during the page's + // lifetime updates the calendar window correctly on subsequent renders. + const boardMinDate = useMemo(() => getBoardMinDate(todayDate), [todayDate]); + const boardMaxDate = useMemo(() => getBoardMaxDate(todayDate), [todayDate]); // TIRREDESIGN-12: fetch the 31-day operating-days bitmask for the // current tab so non-operating days in the [minDate, maxDate] window @@ -198,11 +213,11 @@ export const OnlineBoardFilter: FC = ({ const digits = flightNumber.trim(); if (digits.length < 1 || !/^\d{1,4}$/.test(digits)) return null; return { - date: todayIso(), + date: todayIsoStr, searchType: "flight", flightNumber: `SU${padFlightNumber(digits)}`, }; - }, [activeTab, flightNumber]); + }, [activeTab, flightNumber, todayIsoStr]); const routeCalendarParams = useMemo(() => { if (activeTab !== "route") return null; @@ -211,11 +226,11 @@ export const OnlineBoardFilter: FC = ({ if (!dep && !arr) return null; if (dep && arr) { if (dep === arr) return null; - return { date: todayIso(), searchType: "route", departure: dep, arrival: arr }; + return { date: todayIsoStr, searchType: "route", departure: dep, arrival: arr }; } - if (dep) return { date: todayIso(), searchType: "departure", departure: dep }; - return { date: todayIso(), searchType: "arrival", arrival: arr }; - }, [activeTab, routeDepartureCode, routeArrivalCode]); + if (dep) return { date: todayIsoStr, searchType: "departure", departure: dep }; + return { date: todayIsoStr, searchType: "arrival", arrival: arr }; + }, [activeTab, routeDepartureCode, routeArrivalCode, todayIsoStr]); const { days: flightAvailableDays } = useCalendarDays(flightCalendarParams); const { days: routeAvailableDays } = useCalendarDays(routeCalendarParams); diff --git a/src/features/online-board/components/OnlineBoardStartPage.tsx b/src/features/online-board/components/OnlineBoardStartPage.tsx index 3fe0cb7e..7e888b67 100644 --- a/src/features/online-board/components/OnlineBoardStartPage.tsx +++ b/src/features/online-board/components/OnlineBoardStartPage.tsx @@ -161,7 +161,15 @@ export function buildOnlineBoardPrefillState( } } -export const OnlineBoardStartPage: FC = () => { +export interface OnlineBoardStartPageProps { + /** + * yyyyMMdd of "today" computed once on the SSR server (route loader) + * and passed through to children. See OnlineBoardFilterProps.today. + */ + today?: string; +} + +export const OnlineBoardStartPage: FC = ({ today }) => { const { t } = useTranslation(); const navigate = useNavigate(); const { locale, language } = useLocale(); @@ -293,7 +301,7 @@ export const OnlineBoardStartPage: FC = () => { breadcrumbs={[]} contentLeft={ <> - + } diff --git a/src/routes/[lang]/flights-map/page.tsx b/src/routes/[lang]/flights-map/page.tsx index a2624550..6df8eab9 100644 --- a/src/routes/[lang]/flights-map/page.tsx +++ b/src/routes/[lang]/flights-map/page.tsx @@ -8,13 +8,14 @@ */ import { lazy, Suspense } from "react"; -import { useParams } from "@modern-js/runtime/router"; +import { useParams, useLoaderData } from "@modern-js/runtime/router"; import { useTranslation } from "@/i18n/provider.js"; import { SeoHead } from "@/ui/seo/SeoHead.js"; import { buildFlightsMapSeo } from "@/features/flights-map/seo.js"; import { buildFlightsMapJsonLd } from "@/features/flights-map/json-ld.js"; import { useFeatureFlag } from "@/features/flights-map/hooks/useFeatureFlag.js"; import { getEnv } from "@/env/index.js"; +import { todayYyyymmdd } from "@/shared/utils/datetime/index.js"; const FlightsMapStartPage = lazy(() => import("@/features/flights-map/components/FlightsMapStartPage.js").then( @@ -22,6 +23,11 @@ const FlightsMapStartPage = lazy(() => ), ); +/** SSR-stable today; see src/routes/[lang]/onlineboard/page.tsx loader. */ +export const loader = (): { today: string } => ({ + today: todayYyyymmdd(), +}); + export default function FlightsMapPage(): JSX.Element { const { t } = useTranslation(); const routeParams = useParams<{ lang: string }>(); @@ -31,6 +37,7 @@ export default function FlightsMapPage(): JSX.Element { // Tile URL read on the server (where process.env is available) and passed // down as a prop — the client bundle can't read MAP_TILE_URL itself. const tileUrl = env.MAP_TILE_URL; + const { today } = useLoaderData() as { today: string }; const isEnabled = useFeatureFlag("flightsMap"); if (!isEnabled) { @@ -48,7 +55,7 @@ export default function FlightsMapPage(): JSX.Element { <> {t("SHARED.LOADING")}}> - + ); diff --git a/src/routes/[lang]/onlineboard/page.tsx b/src/routes/[lang]/onlineboard/page.tsx index 5653ceb8..399c51d2 100644 --- a/src/routes/[lang]/onlineboard/page.tsx +++ b/src/routes/[lang]/onlineboard/page.tsx @@ -6,11 +6,12 @@ */ import { lazy, Suspense } from "react"; -import { useParams } from "@modern-js/runtime/router"; +import { useParams, useLoaderData } from "@modern-js/runtime/router"; import { useTranslation } from "@/i18n/provider.js"; import { SeoHead } from "@/ui/seo/SeoHead.js"; import { buildOnlineBoardStartSeo } from "@/features/online-board/seo.js"; import { getEnv } from "@/env/index.js"; +import { todayYyyymmdd } from "@/shared/utils/datetime/index.js"; const OnlineBoardStartPage = lazy(() => import("@/features/online-board/components/OnlineBoardStartPage.js").then( @@ -18,11 +19,23 @@ const OnlineBoardStartPage = lazy(() => ), ); +/** + * Compute "today" once on the server. The result rides _ROUTER_DATA into + * the client bundle, so the first client render — the one that hydrates + * the SSR markup — sees the same yyyyMMdd value the server saw. Without + * this, components calling `new Date()` during render produce different + * markup on the two sides and React throws hydration error #423. + */ +export const loader = (): { today: string } => ({ + today: todayYyyymmdd(), +}); + export default function OnlineBoardPage(): JSX.Element { const { t } = useTranslation(); const routeParams = useParams<{ lang: string }>(); const locale = routeParams.lang ?? "ru-ru"; const canonicalOrigin = getEnv().PROD_ORIGIN; + const { today } = useLoaderData() as { today: string }; const seoProps = buildOnlineBoardStartSeo(t, locale, canonicalOrigin); @@ -30,7 +43,7 @@ export default function OnlineBoardPage(): JSX.Element { <> {t("SHARED.LOADING")}}> - + ); diff --git a/src/shared/utils/datetime/index.ts b/src/shared/utils/datetime/index.ts index 0566e077..b19d0e49 100644 --- a/src/shared/utils/datetime/index.ts +++ b/src/shared/utils/datetime/index.ts @@ -10,6 +10,32 @@ function isRussianLocale(locale: string): boolean { return locale.toLowerCase().startsWith("ru"); } +/** + * Today's date as `yyyyMMdd` (e.g. "20260427"). Reads from the supplied + * `now` argument so SSR loaders can capture the value once and pass it + * to client components, avoiding `new Date()` calls during render that + * would diverge between server and client and trigger hydration mismatches. + */ +export function todayYyyymmdd(now: Date = new Date()): string { + const y = now.getFullYear(); + const m = String(now.getMonth() + 1).padStart(2, "0"); + const d = String(now.getDate()).padStart(2, "0"); + return `${y}${m}${d}`; +} + +/** Parse a `yyyyMMdd` string into a local-midnight `Date`. */ +export function yyyymmddToDate(yyyymmdd: string): Date { + const y = parseInt(yyyymmdd.slice(0, 4), 10); + const m = parseInt(yyyymmdd.slice(4, 6), 10) - 1; + const d = parseInt(yyyymmdd.slice(6, 8), 10); + return new Date(y, m, d); +} + +/** Convert `yyyyMMdd` → `yyyy-MM-dd`. No validation; assumes 8 digits. */ +export function yyyymmddToIso(yyyymmdd: string): string { + return `${yyyymmdd.slice(0, 4)}-${yyyymmdd.slice(4, 6)}-${yyyymmdd.slice(6, 8)}`; +} + /** * Format a duration given in total minutes into a human-readable string. * Russian units mirror Angular's DurationPipe (SHORT-DAY='д.', SHORT-HOUR='ч.',