fix(select): make component rendering stable during SSR

This commit is contained in:
Nicolas Meienberger 2026-03-26 23:28:40 +01:00
parent 9e7755f3d7
commit ce8b0fe6db
4 changed files with 73 additions and 25 deletions

View file

@ -1,19 +1,74 @@
import type * as React from "react";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { cn } from "~/client/lib/utils";
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
const SelectSsrValueContext = React.createContext<{
items: Map<string, string>;
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>()) {
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));
}
if (child.props.children) {
collectSelectItems(child.props.children, items);
}
}
return items;
}
function Select({ children, value, ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
const items = collectSelectItems(children);
return (
<SelectSsrValueContext.Provider value={{ items, value }}>
<SelectPrimitive.Root data-slot="select" value={value} {...props}>
{children}
</SelectPrimitive.Root>
</SelectSsrValueContext.Provider>
);
}
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
function SelectValue({ children, ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
const context = React.useContext(SelectSsrValueContext);
const resolvedChildren = children ?? (context?.value ? context.items.get(context.value) : undefined);
return (
<SelectPrimitive.Value data-slot="select-value" {...props}>
{resolvedChildren}
</SelectPrimitive.Value>
);
}
function SelectTrigger({
@ -29,7 +84,7 @@ function SelectTrigger({
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",
"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}

View file

@ -1,6 +1,5 @@
import { formatDistanceToNow, isValid } from "date-fns";
import { useEffect, useMemo, useState } from "react";
import { authClient } from "~/client/lib/auth-client";
import { Route as RootRoute } from "~/routes/__root";
export type DateInput = Date | string | number | null | undefined;
@ -64,6 +63,7 @@ function getRequiredPart(parts: Intl.DateTimeFormatPart[], type: Intl.DateTimeFo
function formatConfiguredDate(date: Date, options: DateFormatOptions, includeYear: boolean) {
const dateFormat = options.dateFormat ?? DEFAULT_DATE_FORMAT;
const safeDateFormat = DATE_FORMATS.includes(dateFormat) ? dateFormat : DEFAULT_DATE_FORMAT;
const parts = getDateTimeFormat(options.locale, options.timeZone, {
month: "numeric",
day: "numeric",
@ -74,7 +74,7 @@ function formatConfiguredDate(date: Date, options: DateFormatOptions, includeYea
day: getRequiredPart(parts, "day"),
year: getRequiredPart(parts, "year"),
};
const order = includeYear ? DATE_PART_ORDERS[dateFormat] : SHORT_DATE_PART_ORDERS[dateFormat];
const order = includeYear ? DATE_PART_ORDERS[safeDateFormat] : SHORT_DATE_PART_ORDERS[safeDateFormat];
return order.map((part) => values[part]).join("/");
}
@ -163,10 +163,7 @@ export function formatTimeAgo(date: DateInput, now = Date.now()): string {
export function useTimeFormat() {
const { locale, timeZone, dateFormat, timeFormat, now } = RootRoute.useLoaderData();
const session = authClient.useSession();
const [currentNow, setCurrentNow] = useState(now);
const currentDateFormat = (session.data?.user.dateFormat ?? dateFormat) as DateFormatPreference;
const currentTimeFormat = (session.data?.user.timeFormat ?? timeFormat) as TimeFormatPreference;
useEffect(() => {
const nextNow = Date.now();
@ -175,20 +172,14 @@ export function useTimeFormat() {
return useMemo(
() => ({
formatDateTime: (date: DateInput) =>
formatDateTime(date, { locale, timeZone, dateFormat: currentDateFormat, timeFormat: currentTimeFormat }),
formatDateWithMonth: (date: DateInput) =>
formatDateWithMonth(date, { locale, timeZone, dateFormat: currentDateFormat, timeFormat: currentTimeFormat }),
formatDate: (date: DateInput) =>
formatDate(date, { locale, timeZone, dateFormat: currentDateFormat, timeFormat: currentTimeFormat }),
formatShortDate: (date: DateInput) =>
formatShortDate(date, { locale, timeZone, dateFormat: currentDateFormat, timeFormat: currentTimeFormat }),
formatShortDateTime: (date: DateInput) =>
formatShortDateTime(date, { locale, timeZone, dateFormat: currentDateFormat, timeFormat: currentTimeFormat }),
formatTime: (date: DateInput) =>
formatTime(date, { locale, timeZone, dateFormat: currentDateFormat, timeFormat: currentTimeFormat }),
formatDateTime: (date: DateInput) => formatDateTime(date, { locale, timeZone, dateFormat, timeFormat }),
formatDateWithMonth: (date: DateInput) => formatDateWithMonth(date, { locale, timeZone, dateFormat, timeFormat }),
formatDate: (date: DateInput) => formatDate(date, { locale, timeZone, dateFormat, timeFormat }),
formatShortDate: (date: DateInput) => formatShortDate(date, { locale, timeZone, dateFormat, timeFormat }),
formatShortDateTime: (date: DateInput) => formatShortDateTime(date, { locale, timeZone, dateFormat, timeFormat }),
formatTime: (date: DateInput) => formatTime(date, { locale, timeZone, dateFormat, timeFormat }),
formatTimeAgo: (date: DateInput) => formatTimeAgo(date, currentNow),
}),
[locale, timeZone, currentDateFormat, currentTimeFormat, currentNow],
[locale, timeZone, currentNow, dateFormat, timeFormat],
);
}

View file

@ -20,6 +20,7 @@ import { useState } from "react";
import { useNavigate } from "@tanstack/react-router";
import { normalizeUsername } from "~/lib/username";
import { z } from "zod";
import { DEFAULT_DATE_FORMAT, DEFAULT_TIME_FORMAT } from "~/client/lib/datetime";
export const clientMiddleware = [authMiddleware];
@ -60,6 +61,8 @@ export function OnboardingPage() {
const { data, error } = await authClient.signUp.email({
username: normalizeUsername(values.username),
dateFormat: DEFAULT_DATE_FORMAT,
timeFormat: DEFAULT_TIME_FORMAT,
password: values.password,
email: values.email.toLowerCase().trim(),
name: values.username,

View file

@ -172,7 +172,6 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
},
onSuccess: () => {
window.location.reload();
toast.success("Date and time preferences saved");
},
},
});