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:
@@ -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 />
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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='ч.',
|
||||
|
||||
Reference in New Issue
Block a user