Merge fix/ssr-hydration-step1-date-loader
ci-deploy / build-deploy-test (push) Successful in 2m0s

Step 1 of docs/superpowers/specs/2026-04-27-ssr-hydration-fix.md.
Eliminates render-path new Date() drift on /onlineboard and
/flights-map start pages by hoisting today's yyyyMMdd to a route
loader; client hydration reads the SSR-baked value from _ROUTER_DATA.
This commit is contained in:
2026-04-27 20:13:36 +03:00
6 changed files with 120 additions and 43 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);
@@ -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<OnlineBoardFilterProps> = ({
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<OnlineBoardFilterProps> = ({
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<OnlineBoardFilterProps> = ({
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<CalendarParams | null>(() => {
if (activeTab !== "route") return null;
@@ -211,11 +226,11 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
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);
@@ -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<OnlineBoardStartPageProps> = ({ today }) => {
const { t } = useTranslation();
const navigate = useNavigate();
const { locale, language } = useLocale();
@@ -293,7 +301,7 @@ export const OnlineBoardStartPage: FC = () => {
breadcrumbs={[]}
contentLeft={
<>
<OnlineBoardFilter key={filterKey} {...filterInitialProps} />
<OnlineBoardFilter key={filterKey} {...filterInitialProps} {...(today ? { today } : {})} />
<SearchHistory />
</>
}
+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>
</>
);
+15 -2
View File
@@ -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 {
<>
<SeoHead {...seoProps} />
<Suspense fallback={<div aria-busy="true">{t("SHARED.LOADING")}</div>}>
<OnlineBoardStartPage />
<OnlineBoardStartPage today={today} />
</Suspense>
</>
);
+26
View File
@@ -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='ч.',