Skip to content

Commit ce8b0fe

Browse files
committed
fix(select): make component rendering stable during SSR
1 parent 9e7755f commit ce8b0fe

4 files changed

Lines changed: 73 additions & 25 deletions

File tree

app/client/components/ui/select.tsx

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,74 @@
1-
import type * as React from "react";
1+
import * as React from "react";
22
import * as SelectPrimitive from "@radix-ui/react-select";
33
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
44

55
import { cn } from "~/client/lib/utils";
66

7-
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
8-
return <SelectPrimitive.Root data-slot="select" {...props} />;
7+
const SelectSsrValueContext = React.createContext<{
8+
items: Map<string, string>;
9+
value: string | undefined;
10+
} | null>(null);
11+
12+
function getSelectItemText(children: React.ReactNode): string {
13+
return React.Children.toArray(children)
14+
.map((child) => {
15+
if (typeof child === "string" || typeof child === "number") {
16+
return String(child);
17+
}
18+
19+
if (!React.isValidElement<{ children?: React.ReactNode }>(child)) {
20+
return "";
21+
}
22+
23+
return getSelectItemText(child.props.children);
24+
})
25+
.join("")
26+
.trim();
27+
}
28+
29+
function collectSelectItems(children: React.ReactNode, items = new Map<string, string>()) {
30+
for (const child of React.Children.toArray(children)) {
31+
if (!React.isValidElement<{ children?: React.ReactNode; value?: string }>(child)) {
32+
continue;
33+
}
34+
35+
if ((child.type === SelectItem || child.type === SelectPrimitive.Item) && typeof child.props.value === "string") {
36+
items.set(child.props.value, getSelectItemText(child.props.children));
37+
}
38+
39+
if (child.props.children) {
40+
collectSelectItems(child.props.children, items);
41+
}
42+
}
43+
44+
return items;
45+
}
46+
47+
function Select({ children, value, ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
48+
const items = collectSelectItems(children);
49+
50+
return (
51+
<SelectSsrValueContext.Provider value={{ items, value }}>
52+
<SelectPrimitive.Root data-slot="select" value={value} {...props}>
53+
{children}
54+
</SelectPrimitive.Root>
55+
</SelectSsrValueContext.Provider>
56+
);
957
}
1058

1159
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
1260
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
1361
}
1462

15-
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
16-
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
63+
function SelectValue({ children, ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
64+
const context = React.useContext(SelectSsrValueContext);
65+
const resolvedChildren = children ?? (context?.value ? context.items.get(context.value) : undefined);
66+
67+
return (
68+
<SelectPrimitive.Value data-slot="select-value" {...props}>
69+
{resolvedChildren}
70+
</SelectPrimitive.Value>
71+
);
1772
}
1873

1974
function SelectTrigger({
@@ -29,7 +84,7 @@ function SelectTrigger({
2984
data-slot="select-trigger"
3085
data-size={size}
3186
className={cn(
32-
"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",
87+
"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",
3388
className,
3489
)}
3590
{...props}

app/client/lib/datetime.ts

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { formatDistanceToNow, isValid } from "date-fns";
22
import { useEffect, useMemo, useState } from "react";
3-
import { authClient } from "~/client/lib/auth-client";
43
import { Route as RootRoute } from "~/routes/__root";
54

65
export type DateInput = Date | string | number | null | undefined;
@@ -64,6 +63,7 @@ function getRequiredPart(parts: Intl.DateTimeFormatPart[], type: Intl.DateTimeFo
6463

6564
function formatConfiguredDate(date: Date, options: DateFormatOptions, includeYear: boolean) {
6665
const dateFormat = options.dateFormat ?? DEFAULT_DATE_FORMAT;
66+
const safeDateFormat = DATE_FORMATS.includes(dateFormat) ? dateFormat : DEFAULT_DATE_FORMAT;
6767
const parts = getDateTimeFormat(options.locale, options.timeZone, {
6868
month: "numeric",
6969
day: "numeric",
@@ -74,7 +74,7 @@ function formatConfiguredDate(date: Date, options: DateFormatOptions, includeYea
7474
day: getRequiredPart(parts, "day"),
7575
year: getRequiredPart(parts, "year"),
7676
};
77-
const order = includeYear ? DATE_PART_ORDERS[dateFormat] : SHORT_DATE_PART_ORDERS[dateFormat];
77+
const order = includeYear ? DATE_PART_ORDERS[safeDateFormat] : SHORT_DATE_PART_ORDERS[safeDateFormat];
7878

7979
return order.map((part) => values[part]).join("/");
8080
}
@@ -163,10 +163,7 @@ export function formatTimeAgo(date: DateInput, now = Date.now()): string {
163163

164164
export function useTimeFormat() {
165165
const { locale, timeZone, dateFormat, timeFormat, now } = RootRoute.useLoaderData();
166-
const session = authClient.useSession();
167166
const [currentNow, setCurrentNow] = useState(now);
168-
const currentDateFormat = (session.data?.user.dateFormat ?? dateFormat) as DateFormatPreference;
169-
const currentTimeFormat = (session.data?.user.timeFormat ?? timeFormat) as TimeFormatPreference;
170167

171168
useEffect(() => {
172169
const nextNow = Date.now();
@@ -175,20 +172,14 @@ export function useTimeFormat() {
175172

176173
return useMemo(
177174
() => ({
178-
formatDateTime: (date: DateInput) =>
179-
formatDateTime(date, { locale, timeZone, dateFormat: currentDateFormat, timeFormat: currentTimeFormat }),
180-
formatDateWithMonth: (date: DateInput) =>
181-
formatDateWithMonth(date, { locale, timeZone, dateFormat: currentDateFormat, timeFormat: currentTimeFormat }),
182-
formatDate: (date: DateInput) =>
183-
formatDate(date, { locale, timeZone, dateFormat: currentDateFormat, timeFormat: currentTimeFormat }),
184-
formatShortDate: (date: DateInput) =>
185-
formatShortDate(date, { locale, timeZone, dateFormat: currentDateFormat, timeFormat: currentTimeFormat }),
186-
formatShortDateTime: (date: DateInput) =>
187-
formatShortDateTime(date, { locale, timeZone, dateFormat: currentDateFormat, timeFormat: currentTimeFormat }),
188-
formatTime: (date: DateInput) =>
189-
formatTime(date, { locale, timeZone, dateFormat: currentDateFormat, timeFormat: currentTimeFormat }),
175+
formatDateTime: (date: DateInput) => formatDateTime(date, { locale, timeZone, dateFormat, timeFormat }),
176+
formatDateWithMonth: (date: DateInput) => formatDateWithMonth(date, { locale, timeZone, dateFormat, timeFormat }),
177+
formatDate: (date: DateInput) => formatDate(date, { locale, timeZone, dateFormat, timeFormat }),
178+
formatShortDate: (date: DateInput) => formatShortDate(date, { locale, timeZone, dateFormat, timeFormat }),
179+
formatShortDateTime: (date: DateInput) => formatShortDateTime(date, { locale, timeZone, dateFormat, timeFormat }),
180+
formatTime: (date: DateInput) => formatTime(date, { locale, timeZone, dateFormat, timeFormat }),
190181
formatTimeAgo: (date: DateInput) => formatTimeAgo(date, currentNow),
191182
}),
192-
[locale, timeZone, currentDateFormat, currentTimeFormat, currentNow],
183+
[locale, timeZone, currentNow, dateFormat, timeFormat],
193184
);
194185
}

app/client/modules/auth/routes/onboarding.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { useState } from "react";
2020
import { useNavigate } from "@tanstack/react-router";
2121
import { normalizeUsername } from "~/lib/username";
2222
import { z } from "zod";
23+
import { DEFAULT_DATE_FORMAT, DEFAULT_TIME_FORMAT } from "~/client/lib/datetime";
2324

2425
export const clientMiddleware = [authMiddleware];
2526

@@ -60,6 +61,8 @@ export function OnboardingPage() {
6061

6162
const { data, error } = await authClient.signUp.email({
6263
username: normalizeUsername(values.username),
64+
dateFormat: DEFAULT_DATE_FORMAT,
65+
timeFormat: DEFAULT_TIME_FORMAT,
6366
password: values.password,
6467
email: values.email.toLowerCase().trim(),
6568
name: values.username,

app/client/modules/settings/routes/settings.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,6 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
172172
},
173173
onSuccess: () => {
174174
window.location.reload();
175-
toast.success("Date and time preferences saved");
176175
},
177176
},
178177
});

0 commit comments

Comments
 (0)