From ca38f7ca69f3ccb7cfbff12de62eb50bd581f648 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Tue, 31 Mar 2026 21:58:05 +0200 Subject: [PATCH] fix(select): render a div durng ssr to avoid hydration issues --- app/client/components/ui/select.tsx | 84 +++++++++++-------- .../basic-info-section.tsx | 2 +- .../frequency-section.tsx | 4 +- .../modules/backups/routes/create-backup.tsx | 2 +- .../components/create-notification-form.tsx | 2 +- .../notification-forms/generic-form.tsx | 2 +- .../components/create-repository-form.tsx | 4 +- .../rclone-repository-form.tsx | 2 +- .../components/volume-forms/rclone-form.tsx | 2 +- 9 files changed, 59 insertions(+), 45 deletions(-) diff --git a/app/client/components/ui/select.tsx b/app/client/components/ui/select.tsx index 30b7b487..8042fa80 100644 --- a/app/client/components/ui/select.tsx +++ b/app/client/components/ui/select.tsx @@ -5,35 +5,19 @@ import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"; import { cn } from "~/client/lib/utils"; const SelectSsrValueContext = React.createContext<{ - items: Map; + hydrated: boolean; + items: Map; value: string | undefined; } | null>(null); -function getSelectItemText(children: React.ReactNode): string { - return React.Children.toArray(children) - .map((child) => { - if (typeof child === "string" || typeof child === "number") { - return String(child); - } - - if (!React.isValidElement<{ children?: React.ReactNode }>(child)) { - return ""; - } - - return getSelectItemText(child.props.children); - }) - .join("") - .trim(); -} - -function collectSelectItems(children: React.ReactNode, items = new Map()) { +function collectSelectItems(children: React.ReactNode, items = new Map()) { for (const child of React.Children.toArray(children)) { if (!React.isValidElement<{ children?: React.ReactNode; value?: string }>(child)) { continue; } if ((child.type === SelectItem || child.type === SelectPrimitive.Item) && typeof child.props.value === "string") { - items.set(child.props.value, getSelectItemText(child.props.children)); + items.set(child.props.value, child.props.children); } if (child.props.children) { @@ -45,10 +29,15 @@ function collectSelectItems(children: React.ReactNode, items = new Map) { + const [hydrated, setHydrated] = React.useState(false); const items = collectSelectItems(children); + React.useEffect(() => { + setHydrated(true); + }, []); + return ( - + {children} @@ -60,13 +49,22 @@ function SelectGroup({ ...props }: React.ComponentProps; } -function SelectValue({ children, ...props }: React.ComponentProps) { +function SelectValue({ children, placeholder, style, ...props }: React.ComponentProps) { const context = React.useContext(SelectSsrValueContext); - const resolvedChildren = children ?? (context?.value ? context.items.get(context.value) : undefined); + const selectedItem = + context?.value !== undefined && context.value !== "" ? context.items.get(context.value) : undefined; + + if (!context?.hydrated) { + return ( + + {children ?? selectedItem ?? placeholder} + + ); + } return ( - - {resolvedChildren} + + {children} ); } @@ -76,19 +74,35 @@ function SelectTrigger({ size = "default", children, ...props -}: React.ComponentProps & { +}: React.ComponentPropsWithoutRef & { size?: "sm" | "default"; }) { + const context = React.useContext(SelectSsrValueContext); + const hasValue = context?.value !== undefined && context.value !== ""; + const triggerClassName = cn( + "border-input data-placeholder:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&+select[aria-hidden=true]]:hidden [&:has(+select[aria-hidden=true]:last-child)]:mb-0", + className, + ); + + if (!context?.hydrated) { + return ( +
+ {children} + +
+ ); + } + return ( - + {children} diff --git a/app/client/modules/backups/components/create-schedule-form/basic-info-section.tsx b/app/client/modules/backups/components/create-schedule-form/basic-info-section.tsx index 1e2b2ead..836959ac 100644 --- a/app/client/modules/backups/components/create-schedule-form/basic-info-section.tsx +++ b/app/client/modules/backups/components/create-schedule-form/basic-info-section.tsx @@ -42,7 +42,7 @@ export const BasicInfoSection = ({ form, volume }: BasicInfoSectionProps) => { Backup repository - diff --git a/app/client/modules/backups/components/create-schedule-form/frequency-section.tsx b/app/client/modules/backups/components/create-schedule-form/frequency-section.tsx index 47d1654f..40881414 100644 --- a/app/client/modules/backups/components/create-schedule-form/frequency-section.tsx +++ b/app/client/modules/backups/components/create-schedule-form/frequency-section.tsx @@ -22,7 +22,7 @@ export const FrequencySection = ({ form, frequency }: FrequencySectionProps) => Backup frequency - @@ -77,7 +77,7 @@ export const FrequencySection = ({ form, frequency }: FrequencySectionProps) => Execution day - diff --git a/app/client/modules/backups/routes/create-backup.tsx b/app/client/modules/backups/routes/create-backup.tsx index 74deeb9c..c8d54226 100644 --- a/app/client/modules/backups/routes/create-backup.tsx +++ b/app/client/modules/backups/routes/create-backup.tsx @@ -19,7 +19,7 @@ import { Link, useNavigate } from "@tanstack/react-router"; export function CreateBackupPage() { const navigate = useNavigate(); const formId = useId(); - const [selectedVolumeShortId, setSelectedVolumeShortId] = useState(); + const [selectedVolumeShortId, setSelectedVolumeShortId] = useState(""); const { data: volumesData } = useSuspenseQuery({ ...listVolumesOptions(), diff --git a/app/client/modules/notifications/components/create-notification-form.tsx b/app/client/modules/notifications/components/create-notification-form.tsx index 4c51f02f..b476045c 100644 --- a/app/client/modules/notifications/components/create-notification-form.tsx +++ b/app/client/modules/notifications/components/create-notification-form.tsx @@ -176,7 +176,7 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue }); } }} - value={field.value} + value={field.value ?? ""} disabled={mode === "update"} > diff --git a/app/client/modules/notifications/components/notification-forms/generic-form.tsx b/app/client/modules/notifications/components/notification-forms/generic-form.tsx index 7df1a5d7..a62ce1e7 100644 --- a/app/client/modules/notifications/components/notification-forms/generic-form.tsx +++ b/app/client/modules/notifications/components/notification-forms/generic-form.tsx @@ -74,7 +74,7 @@ export const GenericForm = ({ form }: Props) => { render={({ field }) => ( Method - diff --git a/app/client/modules/repositories/components/create-repository-form.tsx b/app/client/modules/repositories/components/create-repository-form.tsx index da67ee25..54b0ffc3 100644 --- a/app/client/modules/repositories/components/create-repository-form.tsx +++ b/app/client/modules/repositories/components/create-repository-form.tsx @@ -173,7 +173,7 @@ export const CreateRepositoryForm = ({ ], }); }} - value={field.value} + value={field.value ?? ""} disabled={mode === "update"} > @@ -213,7 +213,7 @@ export const CreateRepositoryForm = ({ render={({ field }) => ( Compression Mode - diff --git a/app/client/modules/repositories/components/repository-forms/rclone-repository-form.tsx b/app/client/modules/repositories/components/repository-forms/rclone-repository-form.tsx index 106a8681..ce4b97ba 100644 --- a/app/client/modules/repositories/components/repository-forms/rclone-repository-form.tsx +++ b/app/client/modules/repositories/components/repository-forms/rclone-repository-form.tsx @@ -52,7 +52,7 @@ export const RcloneRepositoryForm = ({ form }: Props) => { render={({ field }) => ( Remote - field.onChange(v)} value={field.value ?? ""}> diff --git a/app/client/modules/volumes/components/volume-forms/rclone-form.tsx b/app/client/modules/volumes/components/volume-forms/rclone-form.tsx index fb0ff766..5f6aed13 100644 --- a/app/client/modules/volumes/components/volume-forms/rclone-form.tsx +++ b/app/client/modules/volumes/components/volume-forms/rclone-form.tsx @@ -58,7 +58,7 @@ export const RcloneForm = ({ form }: Props) => { render={({ field }) => ( Remote - field.onChange(v)} value={field.value ?? ""}>