fix(select): make component rendering stable during SSR
This commit is contained in:
parent
9e7755f3d7
commit
ce8b0fe6db
4 changed files with 73 additions and 25 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -172,7 +172,6 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
|||
},
|
||||
onSuccess: () => {
|
||||
window.location.reload();
|
||||
toast.success("Date and time preferences saved");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue