SSR-stable today for FlightsMap route (hydration step 1b)

Same pattern as OnlineBoard: route loader supplies todayYyyymmdd() once
on the server; FlightsMapStartPage threads it through useMemo dep arrays
for searchParams + calendarParams so SSR and client hydration agree on
the same dateFrom/dateTo values.

Removes the local todayYyyymmdd() copy in favour of the shared util.
This commit is contained in:
2026-04-27 20:11:31 +03:00
parent 615c1642b3
commit 5ba34ab507
2 changed files with 32 additions and 17 deletions
@@ -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<FlightsMapStartPageProps> = ({
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<FlightsMapStartPageProps> = ({
const searchParams = useMemo<FlightsMapSearchParams | null>(() => {
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<FlightsMapCalendarParams | null>(() => {
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);
+9 -2
View File
@@ -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 {
<>
<SeoHead {...seoProps} jsonLd={jsonLd} />
<Suspense fallback={<div aria-busy="true">{t("SHARED.LOADING")}</div>}>
<FlightsMapStartPage tileUrl={tileUrl} />
<FlightsMapStartPage tileUrl={tileUrl} today={today} />
</Suspense>
</>
);