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";
const SelectSsrValueContext = React.createContext<{
items: Map<string, string>;
hydrated: boolean;
items: Map<string, React.ReactNode>;
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<string, string>()) {
function collectSelectItems(children: React.ReactNode, items = new Map<string, React.ReactNode>()) {
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<string, s
}
function Select({ children, value, ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
const [hydrated, setHydrated] = React.useState(false);
const items = collectSelectItems(children);
React.useEffect(() => {
setHydrated(true);
}, []);
return (
<SelectSsrValueContext.Provider value={{ items, value }}>
<SelectSsrValueContext.Provider value={{ hydrated, items, value }}>
<SelectPrimitive.Root data-slot="select" value={value} {...props}>
{children}
</SelectPrimitive.Root>
@ -60,13 +49,22 @@ function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.G
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 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 (
<SelectPrimitive.Value data-slot="select-value" {...props}>
{resolvedChildren}
<SelectPrimitive.Value data-slot="select-value" placeholder={placeholder} style={style} {...props}>
{children}
</SelectPrimitive.Value>
);
}
@ -76,19 +74,35 @@ function SelectTrigger({
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
}: React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
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 (
<SelectPrimitive.Trigger
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}
>
<SelectPrimitive.Trigger data-slot="select-trigger" data-size={size} className={triggerClassName} {...props}>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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