fix(ui): hydration mismatch su /tracking e /plan (date calcolate client-side)
I React errors #418/#423/#425 segnalati dal browser nel flow di salvataggio check-in: l'HTML server-rendered divergeva dal client-rendered, causando `Text content does not match`. La submit del check-in falliva silenziosamente. Cause: - `weight-entry-form.tsx` calcolava `todayISO()` (basato su `new Date()`) dentro `defaultValues` di `useForm`. Il render avviene sia SSR che hydration, e fra i due il valore può differire (anche di pochi ms attraverso la mezzanotte locale, oppure per fuso orario server vs client). - `plan-week.tsx` con `todayDayOfWeek()` aveva lo stesso problema sul pannello macros. Fix: il valore viene calcolato server-side nel rispettivo Server Component (`tracking/page.tsx`, `plan/page.tsx`) e passato come prop al Client Component. Stabile attraverso SSR e hydration perché serializzato nell'HTML iniziale. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
f7f6410559
commit
221b567d45
4 files changed
+33
-18
No files matched your search
@@ -26,7 +26,11 @@ export default async function PlanPage({ params: { locale } }: { params: { local
|
||||
|
||||
const [plan, dailyTarget] = await Promise.all([fetchCurrentPlan(), fetchDailyTarget()]);
|
||||
|
||||
return <PlanPageContent plan={plan} dailyTarget={dailyTarget} />;
|
||||
// Calcolato server-side per evitare hydration mismatch nel pannello macros.
|
||||
const dow = new Date().getDay();
|
||||
const todayDayOfWeek = dow === 0 ? 6 : dow - 1;
|
||||
|
||||
return <PlanPageContent plan={plan} dailyTarget={dailyTarget} todayDayOfWeek={todayDayOfWeek} />;
|
||||
}
|
||||
|
||||
const ITALIAN_DATE = new Intl.DateTimeFormat('it-IT', {
|
||||
@@ -49,9 +53,11 @@ function formatWeek(weekStart: string): string {
|
||||
function PlanPageContent({
|
||||
plan,
|
||||
dailyTarget,
|
||||
todayDayOfWeek,
|
||||
}: {
|
||||
plan: Awaited<ReturnType<typeof fetchCurrentPlan>>;
|
||||
dailyTarget: Awaited<ReturnType<typeof fetchDailyTarget>>;
|
||||
todayDayOfWeek: number;
|
||||
}) {
|
||||
const t = useTranslations('Plan');
|
||||
|
||||
@@ -154,7 +160,7 @@ function PlanPageContent({
|
||||
|
||||
{plan ? (
|
||||
<div className="animate-fade-up relative [animation-delay:480ms]">
|
||||
<PlanWeek plan={plan} dailyTarget={dailyTarget} />
|
||||
<PlanWeek plan={plan} dailyTarget={dailyTarget} todayDayOfWeek={todayDayOfWeek} />
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
|
||||
@@ -62,18 +62,16 @@ function aggregateMacros(slots: PlanSlot[]): {
|
||||
|
||||
const FREE_MEAL_DEFAULT_KCAL = 750;
|
||||
|
||||
// PRD §6 — il pannello macros laterale ancora oggi (lun=0…dom=6).
|
||||
function todayDayOfWeek(): number {
|
||||
const d = new Date().getDay(); // 0 = Sunday
|
||||
return d === 0 ? 6 : d - 1;
|
||||
}
|
||||
|
||||
export function PlanWeek({
|
||||
plan,
|
||||
dailyTarget,
|
||||
todayDayOfWeek,
|
||||
}: {
|
||||
plan: CurrentPlan;
|
||||
dailyTarget: DailyTarget | null;
|
||||
/** Indice giorno corrente (lun=0…dom=6) calcolato server-side per evitare
|
||||
* hydration mismatch fra render SSR e client. */
|
||||
todayDayOfWeek: number;
|
||||
}) {
|
||||
const t = useTranslations('Plan');
|
||||
|
||||
@@ -83,7 +81,7 @@ export function PlanWeek({
|
||||
byDay.get(slot.dayOfWeek)!.push(slot);
|
||||
}
|
||||
|
||||
const today = todayDayOfWeek();
|
||||
const today = todayDayOfWeek;
|
||||
const todaySlots = byDay.get(today) ?? [];
|
||||
const todayConsumed = aggregateMacros(todaySlots.filter((s) => s.consumed));
|
||||
const todayPlanned = aggregateMacros(todaySlots);
|
||||
|
||||
@@ -38,11 +38,16 @@ export default async function TrackingPage({ params: { locale } }: { params: { l
|
||||
fetchTodayCheckIn(),
|
||||
]);
|
||||
|
||||
// Calcolato server-side per evitare hydration mismatch su WeightEntryForm.
|
||||
const now = new Date();
|
||||
const todayISO = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||
|
||||
return (
|
||||
<TrackingPageContent
|
||||
entries={entries}
|
||||
profile={profile as ProfileLike}
|
||||
todayCheckIn={todayCheckIn}
|
||||
todayISO={todayISO}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -51,10 +56,12 @@ function TrackingPageContent({
|
||||
entries,
|
||||
profile,
|
||||
todayCheckIn,
|
||||
todayISO,
|
||||
}: {
|
||||
entries: Awaited<ReturnType<typeof fetchWeightEntries>>;
|
||||
profile: ProfileLike;
|
||||
todayCheckIn: Awaited<ReturnType<typeof fetchTodayCheckIn>>;
|
||||
todayISO: string;
|
||||
}) {
|
||||
const t = useTranslations('Tracking');
|
||||
const latest = entries[0] ?? null;
|
||||
@@ -135,7 +142,7 @@ function TrackingPageContent({
|
||||
<div className="relative grid gap-12 md:grid-cols-12">
|
||||
<section className="animate-fade-up [animation-delay:300ms] md:col-span-7">
|
||||
<p className="editorial-eyebrow mb-6">{t('newEntry')}</p>
|
||||
<WeightEntryForm latest={latest} />
|
||||
<WeightEntryForm latest={latest} todayISO={todayISO} />
|
||||
</section>
|
||||
<aside className="md:border-ink/15 animate-fade-up [animation-delay:420ms] md:col-span-5 md:border-l md:pl-10">
|
||||
<p className="editorial-eyebrow mb-6">{t('history')}</p>
|
||||
|
||||
@@ -24,12 +24,16 @@ import { Input } from '@/components/ui/input';
|
||||
|
||||
import { saveWeightEntry, type WeightEntryRow } from './actions';
|
||||
|
||||
function todayISO(): string {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function WeightEntryForm({ latest }: { latest: WeightEntryRow | null }) {
|
||||
export function WeightEntryForm({
|
||||
latest,
|
||||
todayISO,
|
||||
}: {
|
||||
latest: WeightEntryRow | null;
|
||||
/** Data odierna in formato YYYY-MM-DD calcolata server-side. Passata
|
||||
* come prop per evitare hydration mismatch (server e client possono
|
||||
* differire di fuso orario o di pochi ms attraverso la mezzanotte). */
|
||||
todayISO: string;
|
||||
}) {
|
||||
const t = useTranslations('Tracking');
|
||||
const [serverError, setServerError] = useState<string | null>(null);
|
||||
const [savedAt, setSavedAt] = useState<string | null>(null);
|
||||
@@ -41,7 +45,7 @@ export function WeightEntryForm({ latest }: { latest: WeightEntryRow | null }) {
|
||||
const form = useForm<WeightEntryInput>({
|
||||
resolver: zodResolver(weightEntryInputSchema),
|
||||
defaultValues: {
|
||||
date: todayISO(),
|
||||
date: todayISO,
|
||||
weightKg: latest?.weightKg,
|
||||
} as unknown as WeightEntryInput,
|
||||
});
|
||||
@@ -54,7 +58,7 @@ export function WeightEntryForm({ latest }: { latest: WeightEntryRow | null }) {
|
||||
return;
|
||||
}
|
||||
setSavedAt(new Date().toISOString());
|
||||
form.reset({ date: todayISO(), weightKg: input.weightKg } as unknown as WeightEntryInput);
|
||||
form.reset({ date: todayISO, weightKg: input.weightKg } as unknown as WeightEntryInput);
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
Reference in new issue
Block a user