fix: react hydration issues by using the same locale during ssr and hydration
Some checks failed
Release Workflow / determine-release-type (push) Has been cancelled
Release Workflow / checks (push) Has been cancelled
Release Workflow / e2e-tests (push) Has been cancelled
Release Workflow / build-images (push) Has been cancelled
Release Workflow / publish-release (push) Has been cancelled

This commit is contained in:
Nicolas Meienberger 2026-03-31 22:21:35 +02:00
parent ca38f7ca69
commit 63e12868b1
12 changed files with 65 additions and 18 deletions

View file

@ -1,5 +1,6 @@
import { Card, CardContent } from "~/client/components/ui/card";
import { ByteSize } from "~/client/components/bytes-size";
import { useRootLoaderData } from "~/client/hooks/use-root-loader-data";
import type { ResticSnapshotSummaryDto } from "@zerobyte/core/restic";
import { formatDuration } from "~/utils/utils";
@ -7,8 +8,6 @@ type Props = {
summary?: ResticSnapshotSummaryDto | null;
};
const formatCount = (value: number) => value.toLocaleString();
const getDurationLabel = (start: string, end: string) => {
const startMs = new Date(start).getTime();
const endMs = new Date(end).getTime();
@ -17,8 +16,11 @@ const getDurationLabel = (start: string, end: string) => {
};
export const BackupSummaryCard = ({ summary }: Props) => {
const { locale } = useRootLoaderData();
if (!summary) return null;
const formatCount = (value: number) => value.toLocaleString(locale);
const durationLabel = getDurationLabel(summary.backup_start, summary.backup_end);
const topStats = [

View file

@ -1,5 +1,5 @@
import type React from "react";
import { formatBytes } from "~/utils/format-bytes";
import { useFormatBytes } from "~/client/hooks/use-format-bytes";
type ByteSizeProps = {
bytes: number;
@ -14,6 +14,7 @@ type ByteSizeProps = {
};
export function ByteSize(props: ByteSizeProps) {
const formatBytes = useFormatBytes();
const {
bytes,
base = 1000,

View file

@ -1,9 +1,10 @@
import { useEffect, useState } from "react";
import { ByteSize } from "~/client/components/bytes-size";
import { useFormatBytes } from "~/client/hooks/use-format-bytes";
import { useRootLoaderData } from "~/client/hooks/use-root-loader-data";
import { Card } from "~/client/components/ui/card";
import { Progress } from "~/client/components/ui/progress";
import { type RestoreProgressEvent, useServerEvents } from "~/client/hooks/use-server-events";
import { formatBytes } from "~/utils/format-bytes";
import { formatDuration } from "~/utils/utils";
type Props = {
@ -12,6 +13,8 @@ type Props = {
};
export const RestoreProgress = ({ repositoryId, snapshotId }: Props) => {
const formatBytes = useFormatBytes();
const { locale } = useRootLoaderData();
const { addEventListener } = useServerEvents();
const [progress, setProgress] = useState<RestoreProgressEvent | null>(null);
@ -71,9 +74,9 @@ export const RestoreProgress = ({ repositoryId, snapshotId }: Props) => {
<div>
<p className="text-xs uppercase text-muted-foreground">Files</p>
<p className="font-medium">
{progress.files_restored.toLocaleString()}
{progress.files_restored.toLocaleString(locale)}
&nbsp;/&nbsp;
{progress.total_files.toLocaleString()}
{progress.total_files.toLocaleString(locale)}
</p>
</div>
<div>

View file

@ -3,6 +3,7 @@
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { useRootLoaderData } from "~/client/hooks/use-root-loader-data";
import { cn } from "~/client/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
@ -116,6 +117,7 @@ function ChartTooltipContent({
labelKey?: string;
}) {
const { config } = useChart();
const { locale } = useRootLoaderData();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
@ -205,7 +207,7 @@ function ChartTooltipContent({
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
{item.value.toLocaleString(locale)}
</span>
)}
</div>

View file

@ -565,10 +565,16 @@ function SidebarMenuSkeleton({
}: React.ComponentProps<"div"> & {
showIcon?: boolean;
}) {
// Random width between 50 to 90%.
const skeletonId = React.useId();
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
let hash = 0;
for (const char of skeletonId) {
hash = (hash * 31 + char.charCodeAt(0)) % 40;
}
return `${hash + 50}%`;
}, [skeletonId]);
return (
<div

View file

@ -0,0 +1,12 @@
import { useRootLoaderData } from "~/client/hooks/use-root-loader-data";
import { formatBytes, type FormatBytesOptions } from "~/utils/format-bytes";
export const useFormatBytes = () => {
const { locale } = useRootLoaderData();
return (bytes: number, options?: FormatBytesOptions) =>
formatBytes(bytes, {
...options,
locale: options?.locale ?? locale,
});
};

View file

@ -0,0 +1,3 @@
import { Route as RootRoute } from "~/routes/__root";
export const useRootLoaderData = () => RootRoute.useLoaderData();

View file

@ -1,6 +1,6 @@
import { formatDistanceToNow, isValid } from "date-fns";
import { useEffect, useMemo, useState } from "react";
import { Route as RootRoute } from "~/routes/__root";
import { useRootLoaderData } from "~/client/hooks/use-root-loader-data";
export type DateInput = Date | string | number | null | undefined;
@ -201,7 +201,7 @@ export function formatTimeAgo(date: DateInput, now = Date.now()): string {
}
export function useTimeFormat() {
const { locale, timeZone, dateFormat, timeFormat, now } = RootRoute.useLoaderData();
const { locale, timeZone, dateFormat, timeFormat, now } = useRootLoaderData();
const [currentNow, setCurrentNow] = useState(now);
useEffect(() => {

View file

@ -1,11 +1,12 @@
import { useQuery } from "@tanstack/react-query";
import { ByteSize } from "~/client/components/bytes-size";
import { useFormatBytes } from "~/client/hooks/use-format-bytes";
import { useRootLoaderData } from "~/client/hooks/use-root-loader-data";
import { Card } from "~/client/components/ui/card";
import { Progress } from "~/client/components/ui/progress";
import { getBackupProgressOptions } from "~/client/api-client/@tanstack/react-query.gen";
import type { GetBackupProgressResponse } from "~/client/api-client/types.gen";
import { formatDuration } from "~/utils/utils";
import { formatBytes } from "~/utils/format-bytes";
type Props = {
scheduleShortId: string;
@ -13,6 +14,8 @@ type Props = {
};
export const BackupProgressCard = ({ scheduleShortId, initialProgress }: Props) => {
const formatBytes = useFormatBytes();
const { locale } = useRootLoaderData();
const { data: progress } = useQuery({
...getBackupProgressOptions({ path: { shortId: scheduleShortId } }),
initialData: initialProgress,
@ -52,7 +55,7 @@ export const BackupProgressCard = ({ scheduleShortId, initialProgress }: Props)
<p className="font-medium">
{progress ? (
<>
{files_done.toLocaleString()} / {total_files.toLocaleString()}
{files_done.toLocaleString(locale)} / {total_files.toLocaleString(locale)}
</>
) : (
"—"

View file

@ -6,6 +6,7 @@ import {
} from "~/client/api-client/@tanstack/react-query.gen";
import type { GetRepositoryStatsResponse } from "~/client/api-client/types.gen";
import { ByteSize } from "~/client/components/bytes-size";
import { useRootLoaderData } from "~/client/hooks/use-root-loader-data";
import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardTitle } from "~/client/components/ui/card";
import { parseError } from "~/client/lib/errors";
@ -26,6 +27,7 @@ const toSafeNumber = (value: number | undefined) => {
};
export function CompressionStatsChart({ repositoryShortId, initialStats }: Props) {
const { locale } = useRootLoaderData();
const refreshStats = useMutation({
...refreshRepositoryStatsMutation(),
onSuccess: () => {
@ -133,7 +135,9 @@ export function CompressionStatsChart({ repositoryShortId, initialStats }: Props
<div className="flex flex-col gap-1.5 lg:col-span-2">
<div className="text-sm font-medium text-muted-foreground">Snapshots</div>
<div className="flex items-baseline gap-2">
<span className="text-xl font-semibold text-foreground font-mono">{snapshotsCount.toLocaleString()}</span>
<span className="text-xl font-semibold text-foreground font-mono">
{snapshotsCount.toLocaleString(locale)}
</span>
<span className="text-xs text-muted-foreground font-mono">
{compressionProgressPercent.toFixed(1)}% compressed
</span>

View file

@ -19,6 +19,7 @@ import { Input } from "~/client/components/ui/input";
import { Label } from "~/client/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/client/components/ui/tabs";
import { useRootLoaderData } from "~/client/hooks/use-root-loader-data";
import { authClient } from "~/client/lib/auth-client";
import {
DATE_FORMATS,
@ -29,7 +30,6 @@ import {
} from "~/client/lib/datetime";
import { logger } from "~/client/lib/logger";
import { type AppContext } from "~/context";
import { Route as RootRoute } from "~/routes/__root";
import { TwoFactorSection } from "../components/two-factor-section";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { SsoSettingsSection } from "~/client/modules/sso/components/sso-settings-section";
@ -50,7 +50,7 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
const [downloadDialogOpen, setDownloadDialogOpen] = useState(false);
const [downloadPassword, setDownloadPassword] = useState("");
const [isChangingPassword, setIsChangingPassword] = useState(false);
const { locale, dateFormat, timeFormat } = RootRoute.useLoaderData();
const { locale, dateFormat, timeFormat } = useRootLoaderData();
const { tab } = useSearch({ from: "/(dashboard)/settings/" });
const activeTab = tab || "account";

View file

@ -1,9 +1,20 @@
import "./setup.ts";
import { GlobalRegistrator } from "@happy-dom/global-registrator";
import { afterAll, afterEach, beforeAll } from "bun:test";
import { afterAll, afterEach, beforeAll, mock } from "bun:test";
import { client } from "~/client/api-client/client.gen";
import { server } from "~/test/msw/server";
void mock.module("~/client/hooks/use-root-loader-data", () => ({
useRootLoaderData: () => ({
theme: "dark",
locale: "en-US",
timeZone: "UTC",
dateFormat: "MM/DD/YYYY",
timeFormat: "12h",
now: Date.now(),
}),
}));
GlobalRegistrator.register({ url: "http://localhost:3000" });
client.setConfig({