fix(select): render a div durng ssr to avoid hydration issues

This commit is contained in:
Nicolas Meienberger 2026-03-31 21:58:05 +02:00
parent 915b96c218
commit ca38f7ca69
9 changed files with 59 additions and 45 deletions

View file

@ -5,35 +5,19 @@ import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { cn } from "~/client/lib/utils"; import { cn } from "~/client/lib/utils";
const SelectSsrValueContext = React.createContext<{ const SelectSsrValueContext = React.createContext<{
items: Map<string, string>; hydrated: boolean;
items: Map<string, React.ReactNode>;
value: string | undefined; value: string | undefined;
} | null>(null); } | null>(null);
function getSelectItemText(children: React.ReactNode): string { function collectSelectItems(children: React.ReactNode, items = new Map<string, React.ReactNode>()) {
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<string, string>()) {
for (const child of React.Children.toArray(children)) { for (const child of React.Children.toArray(children)) {
if (!React.isValidElement<{ children?: React.ReactNode; value?: string }>(child)) { if (!React.isValidElement<{ children?: React.ReactNode; value?: string }>(child)) {
continue; continue;
} }
if ((child.type === SelectItem || child.type === SelectPrimitive.Item) && typeof child.props.value === "string") { 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) { if (child.props.children) {
@ -45,10 +29,15 @@ function collectSelectItems(children: React.ReactNode, items = new Map<string, s
} }
function Select({ children, value, ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) { function Select({ children, value, ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
const [hydrated, setHydrated] = React.useState(false);
const items = collectSelectItems(children); const items = collectSelectItems(children);
React.useEffect(() => {
setHydrated(true);
}, []);
return ( return (
<SelectSsrValueContext.Provider value={{ items, value }}> <SelectSsrValueContext.Provider value={{ hydrated, items, value }}>
<SelectPrimitive.Root data-slot="select" value={value} {...props}> <SelectPrimitive.Root data-slot="select" value={value} {...props}>
{children} {children}
</SelectPrimitive.Root> </SelectPrimitive.Root>
@ -60,13 +49,22 @@ function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.G
return <SelectPrimitive.Group data-slot="select-group" {...props} />; return <SelectPrimitive.Group data-slot="select-group" {...props} />;
} }
function SelectValue({ children, ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) { function SelectValue({ children, placeholder, style, ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
const context = React.useContext(SelectSsrValueContext); 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 (
<span data-slot="select-value" {...props} style={{ pointerEvents: "none", ...style }}>
{children ?? selectedItem ?? placeholder}
</span>
);
}
return ( return (
<SelectPrimitive.Value data-slot="select-value" {...props}> <SelectPrimitive.Value data-slot="select-value" placeholder={placeholder} style={style} {...props}>
{resolvedChildren} {children}
</SelectPrimitive.Value> </SelectPrimitive.Value>
); );
} }
@ -76,19 +74,35 @@ function SelectTrigger({
size = "default", size = "default",
children, children,
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & { }: React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"; 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 (
<div
id={props.id}
aria-disabled="true"
aria-invalid={props["aria-invalid"]}
data-placeholder={hasValue ? undefined : ""}
data-size={size}
data-slot="select-trigger"
className={triggerClassName}
>
{children}
<ChevronDownIcon className="size-4 opacity-50" />
</div>
);
}
return ( return (
<SelectPrimitive.Trigger <SelectPrimitive.Trigger data-slot="select-trigger" data-size={size} className={triggerClassName} {...props}>
data-slot="select-trigger"
data-size={size}
className={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,
)}
{...props}
>
{children} {children}
<SelectPrimitive.Icon asChild> <SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" /> <ChevronDownIcon className="size-4 opacity-50" />

View file

@ -42,7 +42,7 @@ export const BasicInfoSection = ({ form, volume }: BasicInfoSectionProps) => {
<FormItem className="@medium:col-span-2"> <FormItem className="@medium:col-span-2">
<FormLabel>Backup repository</FormLabel> <FormLabel>Backup repository</FormLabel>
<FormControl> <FormControl>
<Select {...field} onValueChange={field.onChange}> <Select {...field} onValueChange={field.onChange} value={field.value ?? ""}>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a repository" /> <SelectValue placeholder="Select a repository" />
</SelectTrigger> </SelectTrigger>

View file

@ -22,7 +22,7 @@ export const FrequencySection = ({ form, frequency }: FrequencySectionProps) =>
<FormItem> <FormItem>
<FormLabel>Backup frequency</FormLabel> <FormLabel>Backup frequency</FormLabel>
<FormControl> <FormControl>
<Select {...field} onValueChange={field.onChange}> <Select {...field} onValueChange={field.onChange} value={field.value ?? ""}>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select frequency" /> <SelectValue placeholder="Select frequency" />
</SelectTrigger> </SelectTrigger>
@ -77,7 +77,7 @@ export const FrequencySection = ({ form, frequency }: FrequencySectionProps) =>
<FormItem className="@medium:col-span-2"> <FormItem className="@medium:col-span-2">
<FormLabel>Execution day</FormLabel> <FormLabel>Execution day</FormLabel>
<FormControl> <FormControl>
<Select {...field} onValueChange={field.onChange}> <Select {...field} onValueChange={field.onChange} value={field.value ?? ""}>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a day" /> <SelectValue placeholder="Select a day" />
</SelectTrigger> </SelectTrigger>

View file

@ -19,7 +19,7 @@ import { Link, useNavigate } from "@tanstack/react-router";
export function CreateBackupPage() { export function CreateBackupPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const formId = useId(); const formId = useId();
const [selectedVolumeShortId, setSelectedVolumeShortId] = useState<string | undefined>(); const [selectedVolumeShortId, setSelectedVolumeShortId] = useState("");
const { data: volumesData } = useSuspenseQuery({ const { data: volumesData } = useSuspenseQuery({
...listVolumesOptions(), ...listVolumesOptions(),

View file

@ -176,7 +176,7 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
}); });
} }
}} }}
value={field.value} value={field.value ?? ""}
disabled={mode === "update"} disabled={mode === "update"}
> >
<FormControl> <FormControl>

View file

@ -74,7 +74,7 @@ export const GenericForm = ({ form }: Props) => {
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Method</FormLabel> <FormLabel>Method</FormLabel>
<Select onValueChange={field.onChange} value={field.value}> <Select onValueChange={field.onChange} value={field.value ?? ""}>
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select method" /> <SelectValue placeholder="Select method" />

View file

@ -173,7 +173,7 @@ export const CreateRepositoryForm = ({
], ],
}); });
}} }}
value={field.value} value={field.value ?? ""}
disabled={mode === "update"} disabled={mode === "update"}
> >
<FormControl> <FormControl>
@ -213,7 +213,7 @@ export const CreateRepositoryForm = ({
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Compression Mode</FormLabel> <FormLabel>Compression Mode</FormLabel>
<Select onValueChange={field.onChange} value={field.value}> <Select onValueChange={field.onChange} value={field.value ?? ""}>
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select compression mode" /> <SelectValue placeholder="Select compression mode" />

View file

@ -52,7 +52,7 @@ export const RcloneRepositoryForm = ({ form }: Props) => {
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Remote</FormLabel> <FormLabel>Remote</FormLabel>
<Select onValueChange={(v) => field.onChange(v)} value={field.value}> <Select onValueChange={(v) => field.onChange(v)} value={field.value ?? ""}>
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select an rclone remote" /> <SelectValue placeholder="Select an rclone remote" />

View file

@ -58,7 +58,7 @@ export const RcloneForm = ({ form }: Props) => {
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Remote</FormLabel> <FormLabel>Remote</FormLabel>
<Select onValueChange={(v) => field.onChange(v)} value={field.value}> <Select onValueChange={(v) => field.onChange(v)} value={field.value ?? ""}>
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select an rclone remote" /> <SelectValue placeholder="Select an rclone remote" />