From ce8b0fe6dbcfa501242cb671f2e160c48de62ed9 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Thu, 26 Mar 2026 23:28:40 +0100 Subject: [PATCH] fix(select): make component rendering stable during SSR --- app/client/components/ui/select.tsx | 67 +++++++++++++++++-- app/client/lib/datetime.ts | 27 +++----- app/client/modules/auth/routes/onboarding.tsx | 3 + .../modules/settings/routes/settings.tsx | 1 - 4 files changed, 73 insertions(+), 25 deletions(-) diff --git a/app/client/components/ui/select.tsx b/app/client/components/ui/select.tsx index eaf16419..30b7b487 100644 --- a/app/client/components/ui/select.tsx +++ b/app/client/components/ui/select.tsx @@ -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) { - return ; +const SelectSsrValueContext = React.createContext<{ + 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()) { + 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) { + const items = collectSelectItems(children); + + return ( + + + {children} + + + ); } function SelectGroup({ ...props }: React.ComponentProps) { return ; } -function SelectValue({ ...props }: React.ComponentProps) { - return ; +function SelectValue({ children, ...props }: React.ComponentProps) { + const context = React.useContext(SelectSsrValueContext); + const resolvedChildren = children ?? (context?.value ? context.items.get(context.value) : undefined); + + return ( + + {resolvedChildren} + + ); } 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} diff --git a/app/client/lib/datetime.ts b/app/client/lib/datetime.ts index 81f3c92f..592cc784 100644 --- a/app/client/lib/datetime.ts +++ b/app/client/lib/datetime.ts @@ -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], ); } diff --git a/app/client/modules/auth/routes/onboarding.tsx b/app/client/modules/auth/routes/onboarding.tsx index c13954a6..e0041450 100644 --- a/app/client/modules/auth/routes/onboarding.tsx +++ b/app/client/modules/auth/routes/onboarding.tsx @@ -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, diff --git a/app/client/modules/settings/routes/settings.tsx b/app/client/modules/settings/routes/settings.tsx index ea7d43d6..15f44021 100644 --- a/app/client/modules/settings/routes/settings.tsx +++ b/app/client/modules/settings/routes/settings.tsx @@ -172,7 +172,6 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i }, onSuccess: () => { window.location.reload(); - toast.success("Date and time preferences saved"); }, }, });