fix(select): render a div durng ssr to avoid hydration issues
This commit is contained in:
parent
915b96c218
commit
ca38f7ca69
9 changed files with 59 additions and 45 deletions
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue