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 * as SelectPrimitive from "@radix-ui/react-select";
|
||||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
|
|
||||||
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
const SelectSsrValueContext = React.createContext<{
|
||||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
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>) {
|
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
function SelectValue({ children, ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
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({
|
function SelectTrigger({
|
||||||
|
|
@ -29,7 +84,7 @@ function SelectTrigger({
|
||||||
data-slot="select-trigger"
|
data-slot="select-trigger"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { formatDistanceToNow, isValid } from "date-fns";
|
import { formatDistanceToNow, isValid } from "date-fns";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { authClient } from "~/client/lib/auth-client";
|
|
||||||
import { Route as RootRoute } from "~/routes/__root";
|
import { Route as RootRoute } from "~/routes/__root";
|
||||||
|
|
||||||
export type DateInput = Date | string | number | null | undefined;
|
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) {
|
function formatConfiguredDate(date: Date, options: DateFormatOptions, includeYear: boolean) {
|
||||||
const dateFormat = options.dateFormat ?? DEFAULT_DATE_FORMAT;
|
const dateFormat = options.dateFormat ?? DEFAULT_DATE_FORMAT;
|
||||||
|
const safeDateFormat = DATE_FORMATS.includes(dateFormat) ? dateFormat : DEFAULT_DATE_FORMAT;
|
||||||
const parts = getDateTimeFormat(options.locale, options.timeZone, {
|
const parts = getDateTimeFormat(options.locale, options.timeZone, {
|
||||||
month: "numeric",
|
month: "numeric",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
|
|
@ -74,7 +74,7 @@ function formatConfiguredDate(date: Date, options: DateFormatOptions, includeYea
|
||||||
day: getRequiredPart(parts, "day"),
|
day: getRequiredPart(parts, "day"),
|
||||||
year: getRequiredPart(parts, "year"),
|
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("/");
|
return order.map((part) => values[part]).join("/");
|
||||||
}
|
}
|
||||||
|
|
@ -163,10 +163,7 @@ export function formatTimeAgo(date: DateInput, now = Date.now()): string {
|
||||||
|
|
||||||
export function useTimeFormat() {
|
export function useTimeFormat() {
|
||||||
const { locale, timeZone, dateFormat, timeFormat, now } = RootRoute.useLoaderData();
|
const { locale, timeZone, dateFormat, timeFormat, now } = RootRoute.useLoaderData();
|
||||||
const session = authClient.useSession();
|
|
||||||
const [currentNow, setCurrentNow] = useState(now);
|
const [currentNow, setCurrentNow] = useState(now);
|
||||||
const currentDateFormat = (session.data?.user.dateFormat ?? dateFormat) as DateFormatPreference;
|
|
||||||
const currentTimeFormat = (session.data?.user.timeFormat ?? timeFormat) as TimeFormatPreference;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const nextNow = Date.now();
|
const nextNow = Date.now();
|
||||||
|
|
@ -175,20 +172,14 @@ export function useTimeFormat() {
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
formatDateTime: (date: DateInput) =>
|
formatDateTime: (date: DateInput) => formatDateTime(date, { locale, timeZone, dateFormat, timeFormat }),
|
||||||
formatDateTime(date, { locale, timeZone, dateFormat: currentDateFormat, timeFormat: currentTimeFormat }),
|
formatDateWithMonth: (date: DateInput) => formatDateWithMonth(date, { locale, timeZone, dateFormat, timeFormat }),
|
||||||
formatDateWithMonth: (date: DateInput) =>
|
formatDate: (date: DateInput) => formatDate(date, { locale, timeZone, dateFormat, timeFormat }),
|
||||||
formatDateWithMonth(date, { locale, timeZone, dateFormat: currentDateFormat, timeFormat: currentTimeFormat }),
|
formatShortDate: (date: DateInput) => formatShortDate(date, { locale, timeZone, dateFormat, timeFormat }),
|
||||||
formatDate: (date: DateInput) =>
|
formatShortDateTime: (date: DateInput) => formatShortDateTime(date, { locale, timeZone, dateFormat, timeFormat }),
|
||||||
formatDate(date, { locale, timeZone, dateFormat: currentDateFormat, timeFormat: currentTimeFormat }),
|
formatTime: (date: DateInput) => formatTime(date, { locale, timeZone, dateFormat, timeFormat }),
|
||||||
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 }),
|
|
||||||
formatTimeAgo: (date: DateInput) => formatTimeAgo(date, currentNow),
|
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 { useNavigate } from "@tanstack/react-router";
|
||||||
import { normalizeUsername } from "~/lib/username";
|
import { normalizeUsername } from "~/lib/username";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { DEFAULT_DATE_FORMAT, DEFAULT_TIME_FORMAT } from "~/client/lib/datetime";
|
||||||
|
|
||||||
export const clientMiddleware = [authMiddleware];
|
export const clientMiddleware = [authMiddleware];
|
||||||
|
|
||||||
|
|
@ -60,6 +61,8 @@ export function OnboardingPage() {
|
||||||
|
|
||||||
const { data, error } = await authClient.signUp.email({
|
const { data, error } = await authClient.signUp.email({
|
||||||
username: normalizeUsername(values.username),
|
username: normalizeUsername(values.username),
|
||||||
|
dateFormat: DEFAULT_DATE_FORMAT,
|
||||||
|
timeFormat: DEFAULT_TIME_FORMAT,
|
||||||
password: values.password,
|
password: values.password,
|
||||||
email: values.email.toLowerCase().trim(),
|
email: values.email.toLowerCase().trim(),
|
||||||
name: values.username,
|
name: values.username,
|
||||||
|
|
|
||||||
|
|
@ -172,7 +172,6 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
toast.success("Date and time preferences saved");
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue