Replace useIsMounted with useHydrated

Strict improvement because we avoid the flash on clientside navigation.
One practical bug was scroll restoration between tournament teams list
and user page. When user pressed back they ended up at the bottom
of the page because the placeholder (smaller height than actual
content) rendered. With useHydrated this placeholder is no longer
rendered for client side navigations.
This commit is contained in:
Kalle 2026-03-28 07:44:45 +02:00
parent 2a4d6ad80e
commit 3925b73d32
40 changed files with 197 additions and 174 deletions

View File

@ -1,7 +1,7 @@
import clsx from "clsx";
import * as React from "react";
import type { Tables } from "~/db/tables";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useHydrated } from "~/hooks/useHydrated";
import { BLANK_IMAGE_URL, discordAvatarUrl } from "~/utils/urls";
import styles from "./Avatar.module.css";
@ -118,7 +118,7 @@ export function Avatar({
} & React.ButtonHTMLAttributes<HTMLImageElement>) {
const [isErrored, setIsErrored] = React.useState(false);
const [loaded, setLoaded] = React.useState(false);
const isClient = useIsMounted();
const isClient = useHydrated();
const isIdenticon =
!url && (!user?.discordAvatar || isErrored || identiconInput);

View File

@ -5,7 +5,7 @@ import { Link } from "react-router";
import type { GearType, Tables, UserWithPlusTier } from "~/db/tables";
import { useUser } from "~/features/auth/core/user";
import type { BuildWeaponWithTop500Info } from "~/features/builds/builds-types";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useHydrated } from "~/hooks/useHydrated";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import type {
Ability as AbilityType,
@ -56,7 +56,7 @@ export function BuildCard({ build, owner, canEdit = false }: BuildProps) {
const user = useUser();
const { t } = useTranslation(["weapons", "builds", "common", "game-misc"]);
const { formatDate } = useTimeFormat();
const isMounted = useIsMounted();
const isHydrated = useHydrated();
const {
id,
@ -123,9 +123,9 @@ export function BuildCard({ build, owner, canEdit = false }: BuildProps) {
</div>
) : null}
<time
className={clsx("whitespace-nowrap", { invisible: !isMounted })}
className={clsx("whitespace-nowrap", { invisible: !isHydrated })}
>
{isMounted
{isHydrated
? formatDate(databaseTimestampToDate(updatedAt), {
day: "numeric",
month: "long",

View File

@ -4,7 +4,7 @@ import { type AxisOptions, Chart as ReactChart } from "react-charts";
import type { TooltipRendererProps } from "react-charts/types/components/TooltipRenderer";
import { useTranslation } from "react-i18next";
import { Theme, useTheme } from "~/features/theme/core/provider";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useHydrated } from "~/hooks/useHydrated";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import styles from "./Chart.module.css";
@ -25,7 +25,7 @@ export default function Chart({
}) {
const { i18n } = useTranslation();
const theme = useTheme();
const isMounted = useIsMounted();
const isHydrated = useHydrated();
const primaryAxis = React.useMemo<
AxisOptions<(typeof options)[number]["data"][number]>
@ -62,7 +62,7 @@ export default function Chart({
[],
);
if (!isMounted) {
if (!isHydrated) {
return <div className={clsx(styles.container, containerClassName)} />;
}

View File

@ -1,5 +1,5 @@
import * as React from "react";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useHydrated } from "~/hooks/useHydrated";
import { dateToYearMonthDayHourMinuteString, isValidDate } from "~/utils/dates";
import { logger } from "~/utils/logger";
@ -38,20 +38,20 @@ export function DateInput({
}
return [null, ""];
});
const isMounted = useIsMounted();
const isHydrated = useHydrated();
return (
<>
{parsedDate && isMounted && (
{parsedDate && isHydrated && (
<input name={name} type="hidden" value={parsedDate.getTime() ?? ""} />
)}
<input
{...inputProps}
type="datetime-local"
disabled={!isMounted || inputProps.disabled}
disabled={!isHydrated || inputProps.disabled}
// This is important, because SSR will likely have a date in the wrong
// timezone. We can only fill in a value once hydration is over.
value={isMounted ? valueString : ""}
value={isHydrated ? valueString : ""}
min={min ? dateToYearMonthDayHourMinuteString(min) : undefined}
max={max ? dateToYearMonthDayHourMinuteString(max) : undefined}
onChange={(e) => {

View File

@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import { type FetcherWithComponents, useFetcher } from "react-router";
import type { SendouButtonProps } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useHydrated } from "~/hooks/useHydrated";
import invariant from "~/utils/invariant";
import { SubmitButton } from "./SubmitButton";
@ -38,7 +38,7 @@ export function FormWithConfirm({
const componentsFetcher = useFetcher();
const fetcher = _fetcher ?? componentsFetcher;
const isMounted = useIsMounted();
const isHydrated = useHydrated();
const { t } = useTranslation(["common"]);
const [dialogOpen, setDialogOpen] = React.useState(false);
const formRef = React.useRef<HTMLFormElement>(null);
@ -57,7 +57,7 @@ export function FormWithConfirm({
return (
<>
{isMounted
{isHydrated
? // using portal here makes nesting this component in another form work
createPortal(
<fetcher.Form

View File

@ -1,5 +1,5 @@
import type * as React from "react";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useHydrated } from "~/hooks/useHydrated";
import { useTimeFormat } from "~/hooks/useTimeFormat";
export function RelativeTime({
@ -9,13 +9,13 @@ export function RelativeTime({
children: React.ReactNode;
timestamp: number;
}) {
const isMounted = useIsMounted();
const isHydrated = useHydrated();
const { formatDateTime } = useTimeFormat();
return (
<abbr
title={
isMounted
isHydrated
? formatDateTime(new Date(timestamp), {
hour: "numeric",
minute: "numeric",

View File

@ -4,7 +4,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { useFetcher } from "react-router";
import type { SidebarStream } from "~/features/core/streams/streams.server";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useHydrated } from "~/hooks/useHydrated";
import type { LanguageCode } from "~/modules/i18n/config";
import { databaseTimestampToDate, formatDistanceToNow } from "~/utils/dates";
import { navIconUrl, tournamentRegisterPage } from "~/utils/urls";
@ -25,7 +25,7 @@ export function StreamListItems({
savedTournamentIds?: number[];
}) {
const { t, i18n } = useTranslation(["front"]);
const isMounted = useIsMounted();
const isHydrated = useHydrated();
const formatRelativeDate = (timestamp: number) => {
const date = new Date(timestamp * 1000);
@ -94,7 +94,7 @@ export function StreamListItems({
</span>
) : stream.subtitle ? (
stream.subtitle
) : !isMounted ? (
) : !isHydrated ? (
<span className="invisible">Placeholder</span>
) : isUpcoming ? (
formatRelativeDate(stream.startsAt)

View File

@ -12,7 +12,7 @@ import {
} from "react-aria-components";
import { SendouBottomTexts } from "~/components/elements/BottomTexts";
import { SendouCalendar } from "~/components/elements/Calendar";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useHydrated } from "~/hooks/useHydrated";
import styles from "./DatePicker.module.css";
import { SendouLabel } from "./Label";
@ -32,9 +32,9 @@ export function SendouDatePicker<T extends DateValue>({
isRequired,
...rest
}: SendouDatePickerProps<T>) {
const isMounted = useIsMounted();
const isHydrated = useHydrated();
if (!isMounted) {
if (!isHydrated) {
return (
<div>
<SendouLabel required={isRequired}>{label}</SendouLabel>

View File

@ -2,7 +2,7 @@ import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router";
import { SendouDialog } from "~/components/elements/Dialog";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useHydrated } from "~/hooks/useHydrated";
import { LOG_IN_URL, SENDOU_INK_DISCORD_URL } from "~/utils/urls";
import styles from "./UserItem.module.css";
@ -12,7 +12,7 @@ export function LogInButtonContainer({
children: React.ReactNode;
}) {
const { t } = useTranslation();
const isMounted = useIsMounted();
const isHydrated = useHydrated();
const [searchParams] = useSearchParams();
const authError = searchParams.get("authError");
@ -22,7 +22,7 @@ export function LogInButtonContainer({
{children}
</form>
{authError != null &&
isMounted &&
isHydrated &&
createPortal(
<SendouDialog
isDismissable

View File

@ -24,7 +24,7 @@ import { Link, useFetcher, useLocation, useMatches } from "react-router";
import { useUser } from "~/features/auth/core/user";
import { useChatContext } from "~/features/chat/useChatContext";
import { FriendMenu } from "~/features/friends/components/FriendMenu";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useHydrated } from "~/hooks/useHydrated";
import type { RootLoaderData } from "~/root";
import type { Breadcrumb, SendouRouteHandle } from "~/utils/remix.server";
import {
@ -215,7 +215,7 @@ export function Layout({
const { t } = useTranslation(["front", "common"]);
const { formatRelativeDate } = useTimeFormat();
const isMounted = useIsMounted();
const isHydrated = useHydrated();
const location = useLocation();
const headerRef = React.useRef<HTMLElement>(null);
const navOffset = useNavOffset(headerRef);
@ -280,7 +280,7 @@ export function Layout({
to={event.url}
imageUrl={event.logoUrl ?? undefined}
subtitle={
isMounted ? (
isHydrated ? (
formatRelativeDate(event.startTime)
) : (
<span className="invisible">Placeholder</span>

View File

@ -8,7 +8,7 @@ import { LinkButton, SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { Pagination } from "~/components/Pagination";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useHydrated } from "~/hooks/useHydrated";
import { usePagination } from "~/hooks/usePagination";
import { useSearchParamState } from "~/hooks/useSearchParamState";
import { useTimeFormat } from "~/hooks/useTimeFormat";
@ -49,9 +49,9 @@ export function ArtGrid({
revive: (value) =>
itemsToDisplay.find((art) => art.id === Number(value))?.id,
});
const isMounted = useIsMounted();
const isHydrated = useHydrated();
if (!isMounted) return null;
if (!isHydrated) return null;
const bigArt = itemsToDisplay.find((art) => art.id === bigArtId);

View File

@ -21,7 +21,7 @@ import { Placeholder } from "~/components/Placeholder";
import { Table } from "~/components/Table";
import { WeaponSelect } from "~/components/WeaponSelect";
import { useUser } from "~/features/auth/core/user";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useHydrated } from "~/hooks/useHydrated";
import { abilitiesShort } from "~/modules/in-game-lists/abilities";
import type {
Ability as AbilityType,
@ -114,9 +114,9 @@ export const handle: SendouRouteHandle = {
export const shouldRevalidate: ShouldRevalidateFunction = () => false;
export default function BuildAnalyzerShell() {
const isMounted = useIsMounted();
const isHydrated = useHydrated();
if (!isMounted) {
if (!isHydrated) {
return <Placeholder />;
}

View File

@ -8,7 +8,7 @@ import { Flag } from "~/components/Flag";
import { Image, ModeImage } from "~/components/Image";
import { TierPill } from "~/components/TierPill";
import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useHydrated } from "~/hooks/useHydrated";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { databaseTimestampToDate } from "~/utils/dates";
import { navIconUrl } from "~/utils/urls";
@ -25,7 +25,7 @@ export function TournamentCard({
className?: string;
withRelativeTime?: boolean;
}) {
const isMounted = useIsMounted();
const isHydrated = useHydrated();
const { formatDateTimeSmartMinutes, formatDistanceToNow } = useTimeFormat();
const isShowcase = tournament.type === "showcase";
@ -34,7 +34,7 @@ export function TournamentCard({
const time = () => {
if (!isShowcase) return null;
if (!isMounted) return "Placeholder";
if (!isHydrated) return "Placeholder";
const date = databaseTimestampToDate(tournament.startTime);
@ -101,7 +101,7 @@ export function TournamentCard({
{isShowcase ? (
<time
className={clsx(styles.time, {
invisible: !isMounted,
invisible: !isHydrated,
})}
dateTime={databaseTimestampToDate(
tournament.startTime,

View File

@ -14,7 +14,7 @@ import { Section } from "~/components/Section";
import { Table } from "~/components/Table";
import { useUser } from "~/features/auth/core/user";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useHydrated } from "~/hooks/useHydrated";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { databaseTimestampToDate } from "~/utils/dates";
import type { SendouRouteHandle } from "~/utils/remix.server";
@ -81,7 +81,7 @@ export default function CalendarEventPage() {
const user = useUser();
const data = useLoaderData<typeof loader>();
const { t } = useTranslation(["common", "calendar"]);
const isMounted = useIsMounted();
const isHydrated = useHydrated();
const { formatDateTime } = useTimeFormat();
return (
@ -100,7 +100,7 @@ export default function CalendarEventPage() {
})}
</span>
<time dateTime={databaseTimestampToDate(startTime).toISOString()}>
{isMounted
{isHydrated
? formatDateTime(databaseTimestampToDate(startTime), {
hour: "numeric",
minute: "numeric",

View File

@ -22,7 +22,7 @@ import { SubmitButton } from "~/components/SubmitButton";
import type { CalendarEventTag, Tables } from "~/db/tables";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import * as Progression from "~/features/tournament-bracket/core/Progression";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useHydrated } from "~/hooks/useHydrated";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import type { RankedModeShort } from "~/modules/in-game-lists/types";
import { useHasRole } from "~/modules/permissions/hooks";
@ -460,8 +460,8 @@ function DatesInput({ allowMultiDate }: { allowMultiDate?: boolean }) {
const datesCount = datesInputState.length;
const isMounted = useIsMounted();
const usersTimeZone = isMounted
const isHydrated = useHydrated();
const usersTimeZone = isHydrated
? Intl.DateTimeFormat().resolvedOptions().timeZone
: "";
const NEW_CALENDAR_EVENT_HOURS_OFFSET = 24;
@ -541,7 +541,7 @@ function DatesInput({ allowMultiDate }: { allowMultiDate?: boolean }) {
</div>
{datesCount < CALENDAR_EVENT.MAX_AMOUNT_OF_DATES &&
allowMultiDate && <AddButton onAdd={addDate} />}
<FormMessage type="info" className={clsx({ invisible: !isMounted })}>
<FormMessage type="info" className={clsx({ invisible: !isHydrated })}>
{t("calendar:inYourTimeZone")} {usersTimeZone}
</FormMessage>
</div>

View File

@ -2,7 +2,7 @@ import { useState } from "react";
import type { MetaFunction, ShouldRevalidateFunction } from "react-router";
import { Main } from "~/components/Main";
import { Placeholder } from "~/components/Placeholder";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useHydrated } from "~/hooks/useHydrated";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { COMP_ANALYZER_URL, navIconUrl } from "~/utils/urls";
@ -37,9 +37,9 @@ export const handle: SendouRouteHandle = {
export const shouldRevalidate: ShouldRevalidateFunction = () => false;
export default function CompAnalyzerShell() {
const isMounted = useIsMounted();
const isHydrated = useHydrated();
if (!isMounted) {
if (!isHydrated) {
return <Placeholder />;
}

View File

@ -16,7 +16,7 @@ import { TournamentCard } from "~/features/calendar/components/TournamentCard";
import { SplatoonRotations } from "~/features/front-page/components/SplatoonRotations";
import type * as Changelog from "~/features/front-page/core/Changelog.server";
import * as Seasons from "~/features/mmr/core/Seasons";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useHydrated } from "~/hooks/useHydrated";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import styles from "~/styles/front.module.css";
import type { SendouRouteHandle } from "~/utils/remix.server";
@ -66,10 +66,10 @@ function SeasonDates({
season: ReturnType<typeof useSeasonData>["season"];
className: string;
}) {
const isMounted = useIsMounted();
const isHydrated = useHydrated();
const { formatDate } = useTimeFormat();
return isMounted ? (
return isHydrated ? (
<div className={className}>
{formatDate(season.starts, { month: "long", day: "numeric" })} -{" "}
{formatDate(season.ends, { month: "long", day: "numeric" })}

View File

@ -12,7 +12,7 @@ import { Image, TierImage, WeaponImage } from "~/components/Image";
import { useUser } from "~/features/auth/core/user";
import * as Seasons from "~/features/mmr/core/Seasons";
import type { TieredSkill } from "~/features/mmr/tiered.server";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useHydrated } from "~/hooks/useHydrated";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { useHasRole } from "~/modules/permissions/hooks";
import { databaseTimestampToDate } from "~/utils/dates";
@ -92,7 +92,7 @@ function TeamLFGPost({
post: Post & { team: NonNullable<Post["team"]> };
tiersMap: TiersMap;
}) {
const isMounted = useIsMounted();
const isHydrated = useHydrated();
const user = useUser();
const isAdmin = useHasRole("ADMIN");
const [isExpanded, setIsExpanded] = React.useState(false);
@ -104,7 +104,7 @@ function TeamLFGPost({
<div className="stack horizontal items-center justify-between">
<PostTeamLogoHeader team={post.team} />
<div className="stack horizontal items-center sm">
{isMounted && <PostTimezonePill timezone={post.timezone} />}
{isHydrated && <PostTimezonePill timezone={post.timezone} />}
{post.languages && (
<PostLanguagePill languages={post.languages} />
)}
@ -305,18 +305,18 @@ function PostPills({
canEdit?: boolean;
postId: number;
}) {
const isMounted = useIsMounted();
const isHydrated = useHydrated();
return (
<div
className={clsx("stack sm xs-row horizontal flex-wrap", {
invisible: !isMounted,
invisible: !isHydrated,
})}
>
{typeof timezone === "string" && isMounted && (
{typeof timezone === "string" && isHydrated && (
<PostTimezonePill timezone={timezone} />
)}
{!isMounted && <PostTimezonePillPlaceholder />}
{!isHydrated && <PostTimezonePillPlaceholder />}
{typeof plusTier === "number" && (
<PostPlusServerPill plusTier={plusTier} />
)}

View File

@ -1,7 +1,7 @@
import { lazy } from "react";
import type { MetaFunction } from "react-router";
import { Placeholder } from "~/components/Placeholder";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useHydrated } from "~/hooks/useHydrated";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { navIconUrl, PLANNER_URL } from "~/utils/urls";
@ -30,9 +30,9 @@ export const handle: SendouRouteHandle = {
const Planner = lazy(() => import("~/features/map-planner/components/Planner"));
export default function MapPlannerPage() {
const isMounted = useIsMounted();
const isHydrated = useHydrated();
if (!isMounted) return <Placeholder />;
if (!isHydrated) return <Placeholder />;
return <Planner />;
}

View File

@ -7,7 +7,7 @@ import * as R from "remeda";
import type { z } from "zod";
import { LinkButton, SendouButton } from "~/components/elements/Button";
import { useUser } from "~/features/auth/core/user";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useHydrated } from "~/hooks/useHydrated";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { databaseTimestampToDate } from "~/utils/dates";
import { metaTags } from "~/utils/remix";
@ -59,9 +59,9 @@ export default function ScrimsPage() {
const user = useUser();
const { t } = useTranslation(["calendar", "scrims"]);
const data = useLoaderData<typeof loader>();
const isMounted = useIsMounted();
const isHydrated = useHydrated();
if (!isMounted)
if (!isHydrated)
return (
<Main>
<div className={styles.placeholder} />

View File

@ -33,7 +33,7 @@ import { useRecentlyReportedWeapons } from "~/features/sendouq/q-hooks";
import { AddPrivateNoteDialog } from "~/features/sendouq-match/components/AddPrivateNoteDialog";
import type { ReportedWeaponForMerging } from "~/features/sendouq-match/core/reported-weapons.server";
import { resolveRoomPass } from "~/features/tournament-bracket/tournament-bracket-utils";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useHydrated } from "~/hooks/useHydrated";
import { useMainContentWidth } from "~/hooks/useMainContentWidth";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
@ -93,9 +93,9 @@ export const handle: SendouRouteHandle = {
};
export default function QMatchShell() {
const isMounted = useIsMounted();
const isHydrated = useHydrated();
if (!isMounted)
if (!isHydrated)
return (
<Main>
<Placeholder />
@ -108,7 +108,7 @@ export default function QMatchShell() {
function QMatchPage() {
const user = useUser();
const isStaff = useHasRole("STAFF");
const isMounted = useIsMounted();
const isHydrated = useHydrated();
const { t } = useTranslation(["q"]);
const { formatDateTime } = useTimeFormat();
const data = useLoaderData<typeof loader>();
@ -161,10 +161,10 @@ function QMatchPage() {
<h2>{t("q:match.header", { number: data.match.id })}</h2>
<div
className={clsx("text-xs text-lighter", {
invisible: !isMounted,
invisible: !isHydrated,
})}
>
{isMounted
{isHydrated
? formatDateTime(databaseTimestampToDate(data.match.createdAt), {
day: "numeric",
month: "numeric",
@ -249,7 +249,7 @@ function Score({
reportedAt: number;
ownTeamReported: boolean;
}) {
const isMounted = useIsMounted();
const isHydrated = useHydrated();
const { t } = useTranslation(["q"]);
const { formatDateTime } = useTimeFormat();
const data = useLoaderData<typeof loader>();
@ -300,10 +300,10 @@ function Score({
<div className="text-lg font-bold">{score.join(" - ")}</div>
{data.match.isLocked ? (
<div
className={clsx("text-xs text-lighter", { invisible: !isMounted })}
className={clsx("text-xs text-lighter", { invisible: !isHydrated })}
>
{t("q:match.reportedBy", { name: reporter?.username ?? "admin" })}{" "}
{isMounted
{isHydrated
? formatDateTime(databaseTimestampToDate(reportedAt), {
day: "numeric",
month: "numeric",
@ -653,7 +653,7 @@ function BottomSection({
const { t } = useTranslation(["q", "common"]);
const width = useMainContentWidth();
const isMobile = width < 650;
const isMounted = useIsMounted();
const isHydrated = useHydrated();
const user = useUser();
const isStaff = useHasRole("STAFF");
const data = useLoaderData<typeof loader>();
@ -669,7 +669,7 @@ function BottomSection({
return `SQ${lastDigit}`;
};
if (!isMounted) return null;
if (!isHydrated) return null;
const mapListElement = (
<MapList

View File

@ -15,7 +15,7 @@ import {
} from "~/features/chat/chat-utils";
import { updateNoScreenSchema } from "~/features/settings/settings-schemas";
import { SendouForm } from "~/form/SendouForm";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useHydrated } from "~/hooks/useHydrated";
import { modesShort } from "~/modules/in-game-lists/modes";
import type { ModeShort } from "~/modules/in-game-lists/types";
import { metaTags } from "~/utils/remix";
@ -304,7 +304,7 @@ function WeaponPool() {
function Sounds() {
const { t } = useTranslation(["q"]);
const isMounted = useIsMounted();
const isHydrated = useHydrated();
return (
<details>
@ -314,8 +314,8 @@ function Sounds() {
</div>
</summary>
<div className="mb-4">
{isMounted && <SoundCheckboxes />}
{isMounted && <SoundSlider />}
{isHydrated && <SoundCheckboxes />}
{isHydrated && <SoundSlider />}
</div>
</details>
);

View File

@ -6,7 +6,7 @@ import { Avatar } from "~/components/Avatar";
import { TierImage, WeaponImage } from "~/components/Image";
import { Main } from "~/components/Main";
import { useAutoRerender } from "~/hooks/useAutoRerender";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useHydrated } from "~/hooks/useHydrated";
import { twitchThumbnailUrlToSrc } from "~/modules/twitch/utils";
import { databaseTimestampToDate } from "~/utils/dates";
import { metaTags } from "~/utils/remix";
@ -125,10 +125,10 @@ export default function SendouQStreamsPage() {
function RelativeStartTime({ startedAt }: { startedAt: Date }) {
const { i18n } = useTranslation();
const isMounted = useIsMounted();
const isHydrated = useHydrated();
useAutoRerender();
if (!isMounted) return null;
if (!isHydrated) return null;
const minutesAgo = Math.floor((startedAt.getTime() - Date.now()) / 1000 / 60);
const formatter = new Intl.RelativeTimeFormat(i18n.language, {

View File

@ -18,7 +18,7 @@ import { Placeholder } from "~/components/Placeholder";
import { SubmitButton } from "~/components/SubmitButton";
import { useUser } from "~/features/auth/core/user";
import { useAutoRefresh } from "~/hooks/useAutoRefresh";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useHydrated } from "~/hooks/useHydrated";
import { useMainContentWidth } from "~/hooks/useMainContentWidth";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { metaTags } from "~/utils/remix";
@ -62,9 +62,9 @@ export const meta: MetaFunction = (args) => {
};
export default function QLookingShell() {
const isMounted = useIsMounted();
const isHydrated = useHydrated();
if (!isMounted)
if (!isHydrated)
return (
<Main>
<Placeholder />
@ -115,7 +115,7 @@ function QLookingPage() {
function InfoText() {
const { t } = useTranslation(["q"]);
const isMounted = useIsMounted();
const isHydrated = useHydrated();
const data = useLoaderData<typeof loader>();
const fetcher = useFetcher();
const { formatTime } = useTimeFormat();
@ -165,7 +165,7 @@ function InfoText() {
return (
<div
className={clsx("text-xs text-lighter stack horizontal justify-between", {
invisible: !isMounted,
invisible: !isHydrated,
})}
>
<div className="stack sm horizontal">
@ -181,7 +181,7 @@ function InfoText() {
<StreamsLinkButton />
</div>
<span className="text-xxs">
{isMounted
{isHydrated
? t("q:looking.lastUpdatedAt", {
time: formatTime(new Date(data.lastUpdated)),
})
@ -211,11 +211,11 @@ function StreamsLinkButton() {
function Groups() {
const { t } = useTranslation(["q"]);
const data = useLoaderData<typeof loader>();
const isMounted = useIsMounted();
const isHydrated = useHydrated();
const width = useMainContentWidth();
if (!isMounted) return null;
if (!isHydrated) return null;
const isMobile = width < IS_Q_LOOKING_MOBILE_BREAKPOINT;
const isFullGroup =

View File

@ -17,7 +17,7 @@ import type { Tables } from "~/db/tables";
import { useUser } from "~/features/auth/core/user";
import type * as Seasons from "~/features/mmr/core/Seasons";
import { useAutoRerender } from "~/hooks/useAutoRerender";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useHydrated } from "~/hooks/useHydrated";
import { useHasRole } from "~/modules/permissions/hooks";
import { metaTags, type SerializeFrom } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
@ -208,7 +208,7 @@ const clockFormatter = ({
minute: "numeric",
});
function Clocks() {
const isMounted = useIsMounted();
const isHydrated = useHydrated();
const { t, i18n } = useTranslation(["q"]);
useAutoRerender();
@ -221,8 +221,8 @@ function Clocks() {
{t(`q:front.cities.${country.city}`)}
</div>
<Flag countryCode={country.countryCode} />
<div className={clsx({ invisible: !isMounted })}>
{isMounted
<div className={clsx({ invisible: !isHydrated })}>
{isHydrated
? weekdayFormatter({
timeZone: country.timeZone,
locale: i18n.language,
@ -230,8 +230,8 @@ function Clocks() {
: // take space
"Monday"}
</div>
<div className={clsx({ invisible: !isMounted })}>
{isMounted
<div className={clsx({ invisible: !isHydrated })}>
{isHydrated
? clockFormatter({
timeZone: country.timeZone,
locale: i18n.language,
@ -294,7 +294,7 @@ function ActiveSeasonInfo({
season: SerializeFrom<Seasons.ListItem>;
}) {
const { t, i18n } = useTranslation(["q"]);
const isMounted = useIsMounted();
const isHydrated = useHydrated();
const starts = new Date(season.starts);
const ends = new Date(season.ends);
@ -310,11 +310,11 @@ function ActiveSeasonInfo({
return (
<div
className={clsx("text-lighter text-xs text-center", {
invisible: !isMounted,
invisible: !isHydrated,
})}
>
{t("q:front.seasonOpen", { nth: season.nth })}{" "}
{isMounted ? (
{isHydrated ? (
<b>
{dateToString(starts)} - {dateToString(ends)}
</b>
@ -401,8 +401,8 @@ function UpcomingSeasonInfo({
season: SerializeFrom<Seasons.ListItem>;
}) {
const { t } = useTranslation(["q"]);
const isMounted = useIsMounted();
if (!isMounted) return null;
const isHydrated = useHydrated();
if (!isHydrated) return null;
const starts = new Date(season.starts);

View File

@ -30,7 +30,7 @@ import { ModeImage } from "~/components/Image";
import { Main } from "~/components/Main";
import { Placeholder } from "~/components/Placeholder";
import { useUser } from "~/features/auth/core/user";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useHydrated } from "~/hooks/useHydrated";
import { modesShort } from "~/modules/in-game-lists/modes";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
@ -65,9 +65,9 @@ export const handle: SendouRouteHandle = {
};
export default function TierListMakerPage() {
const isMounted = useIsMounted();
const isHydrated = useHydrated();
if (!isMounted)
if (!isHydrated)
return (
<Main bigger>
<Placeholder />

View File

@ -23,7 +23,7 @@ import {
isLeagueRoundLocked,
resolveLeagueRoundStartDate,
} from "~/features/tournament/tournament-utils";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useHydrated } from "~/hooks/useHydrated";
import { useSearchParamState } from "~/hooks/useSearchParamState";
import type { StageId } from "~/modules/in-game-lists/types";
import { SPLATTERCOLOR_SCREEN_ID } from "~/modules/in-game-lists/weapon-ids";
@ -78,7 +78,7 @@ export function StartedMatch({
type: "EDIT" | "OTHER";
}) {
const { t } = useTranslation(["tournament"]);
const isMounted = useIsMounted();
const isHydrated = useHydrated();
const user = useUser();
const tournament = useTournament();
const data = useLoaderData<TournamentMatchLoaderData>();
@ -252,11 +252,11 @@ export function StartedMatch({
{result ? (
<div
className={clsx("text-center text-xs text-lighter", {
invisible: !isMounted,
invisible: !isHydrated,
})}
data-testid="report-timestamp"
>
{isMounted
{isHydrated
? databaseTimestampToDate(result.createdAt).toLocaleString()
: "t"}
</div>

View File

@ -17,7 +17,7 @@ import {
} from "~/components/elements/Tabs";
import { useUser } from "~/features/auth/core/user";
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useHydrated } from "~/hooks/useHydrated";
import { useSearchParamState } from "~/hooks/useSearchParamState";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { useVisibilityChange } from "~/hooks/useVisibilityChange";
@ -45,7 +45,7 @@ export default function TournamentBracketsPage() {
const { revalidate } = useRevalidator();
const user = useUser();
const tournament = useTournament();
const isMounted = useIsMounted();
const isHydrated = useHydrated();
const ctx = useOutletContext();
const defaultBracketIdx = () => {
@ -88,7 +88,7 @@ export default function TournamentBracketsPage() {
tournament.isOrganizer(user) &&
!bracket.canBeStarted &&
bracket.preview &&
isMounted;
isHydrated;
const waitingForTeamsText = () => {
if (bracketIdx > 0 || tournament.regularCheckInStartInThePast) {
@ -252,7 +252,7 @@ function BracketStarter({
bracketIdx: number;
}) {
const [dialogOpen, setDialogOpen] = React.useState(false);
const isMounted = useIsMounted();
const isHydrated = useHydrated();
const close = React.useCallback(() => {
setDialogOpen(false);
@ -260,7 +260,7 @@ function BracketStarter({
return (
<>
{isMounted && dialogOpen ? (
{isHydrated && dialogOpen ? (
<BracketMapListDialog
close={close}
bracket={bracket}
@ -308,7 +308,7 @@ function MapPreparer({
bracketIdx: number;
}) {
const [dialogOpen, setDialogOpen] = React.useState(false);
const isMounted = useIsMounted();
const isHydrated = useHydrated();
const prepared = useTournamentPreparedMaps();
const tournament = useTournament();
@ -326,7 +326,7 @@ function MapPreparer({
return (
<>
{isMounted && dialogOpen ? (
{isHydrated && dialogOpen ? (
<BracketMapListDialog
close={close}
bracket={bracket}

View File

@ -22,7 +22,7 @@ import { useUser } from "~/features/auth/core/user";
import { IS_Q_LOOKING_MOBILE_BREAKPOINT } from "~/features/sendouq/q-constants";
import { useTournament } from "~/features/tournament/routes/to.$id";
import { SendouForm } from "~/form/SendouForm";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useHydrated } from "~/hooks/useHydrated";
import { useMainContentWidth } from "~/hooks/useMainContentWidth";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { userPage } from "~/utils/urls";
@ -47,9 +47,9 @@ export const handle: SendouRouteHandle = {
};
export default function TournamentLFGShell() {
const isMounted = useIsMounted();
const isHydrated = useHydrated();
if (!isMounted) return <Placeholder />;
if (!isHydrated) return <Placeholder />;
return <TournamentLFGPage />;
}

View File

@ -2,7 +2,7 @@ import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { LinkButton } from "~/components/elements/Button";
import type { MonthYear } from "~/features/plus-voting/core";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useHydrated } from "~/hooks/useHydrated";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { databaseTimestampToDate, nullPaddedDatesOfMonth } from "~/utils/dates";
import type { SerializeFrom } from "~/utils/remix";
@ -23,7 +23,7 @@ export function EventCalendar({
fallbackLogoUrl,
}: EventCalendarProps) {
const dates = nullPaddedDatesOfMonth({ month, year });
const isMounted = useIsMounted();
const isHydrated = useHydrated();
const { i18n } = useTranslation();
const dayHeaders = Array.from({ length: 7 }, (_, i) => {
@ -47,7 +47,7 @@ export function EventCalendar({
const startTimeDate = databaseTimestampToDate(event.startTime);
return (
isMounted &&
isHydrated &&
startTimeDate.getDate() === date?.getUTCDate() &&
startTimeDate.getMonth() === date.getUTCMonth()
);
@ -76,14 +76,14 @@ function EventCalendarCell({
events: SerializeFrom<typeof loader>["events"];
fallbackLogoUrl: string;
}) {
const isMounted = useIsMounted();
const isHydrated = useHydrated();
return (
<div
className={clsx(styles.calendarDay, {
[styles.calendarDayPrevious]: !date,
[styles.calendarDayToday]:
isMounted &&
isHydrated &&
date?.getDate() === new Date().getDate() &&
date?.getMonth() === new Date().getMonth() &&
date?.getFullYear() === new Date().getFullYear(),

View File

@ -23,7 +23,7 @@ import { useUser } from "~/features/auth/core/user";
import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay";
import { BannedUsersList } from "~/features/tournament-organization/components/BannedPlayersList";
import { SendouForm } from "~/form/SendouForm";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useHydrated } from "~/hooks/useHydrated";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { useHasPermission, useHasRole } from "~/modules/permissions/hooks";
import { databaseTimestampNow, databaseTimestampToDate } from "~/utils/dates";
@ -503,7 +503,7 @@ function EventInfo({
showYear?: boolean;
}) {
const { formatDateTime } = useTimeFormat();
const isMounted = useIsMounted();
const isHydrated = useHydrated();
return (
<div className="stack sm">
@ -521,9 +521,9 @@ function EventInfo({
<div>
<div>{event.name}</div>
<time
className={clsx(styles.eventInfoTime, { invisible: !isMounted })}
className={clsx(styles.eventInfoTime, { invisible: !isHydrated })}
>
{isMounted
{isHydrated
? formatDateTime(databaseTimestampToDate(event.startTime), {
day: "numeric",
month: "numeric",

View File

@ -46,7 +46,7 @@ import { MapPool } from "~/features/map-list-generator/core/map-pool";
import { ModeMapPoolPicker } from "~/features/sendouq-settings/components/ModeMapPoolPicker";
import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server";
import { useAutoRerender } from "~/hooks/useAutoRerender";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useHydrated } from "~/hooks/useHydrated";
import { useSearchParamState } from "~/hooks/useSearchParamState";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { modesShort, rankedModesShort } from "~/modules/in-game-lists/modes";
@ -78,7 +78,7 @@ import { useTournament } from "./to.$id";
export { action, loader };
export default function TournamentRegisterPage() {
const isMounted = useIsMounted();
const isHydrated = useHydrated();
const tournament = useTournament();
return (
@ -122,7 +122,7 @@ export default function TournamentRegisterPage() {
<div className={clsx(styles.by, "mt-2")}>
<div className="stack horizontal xs items-center">
<Clock className={styles.infoIcon} />{" "}
{isMounted ? (
{isHydrated ? (
<TimePopover
time={tournament.ctx.startTime}
options={{
@ -393,7 +393,7 @@ function RegistrationProgress({
}) {
const { t } = useTranslation(["tournament"]);
const tournament = useTournament();
const isMounted = useIsMounted();
const isHydrated = useHydrated();
const { formatDate } = useTimeFormat();
const completedIfTruthy = (condition: unknown) =>
@ -434,7 +434,7 @@ function RegistrationProgress({
tournament.registrationClosesAt.getTime() !==
tournament.ctx.startTime.getTime();
const registrationClosesAtString = isMounted
const registrationClosesAtString = isHydrated
? formatDate(
tournament.isLeagueSignup
? tournament.ctx.startTime
@ -523,13 +523,13 @@ function CheckIn({
checkedIn?: boolean;
}) {
const { t } = useTranslation(["tournament"]);
const isMounted = useIsMounted();
const isHydrated = useHydrated();
const fetcher = useFetcher();
const { formatTime } = useTimeFormat();
useAutoRerender();
const checkInStartsString = isMounted
const checkInStartsString = isHydrated
? formatTime(startDate, {
minute: "numeric",
hour: "numeric",
@ -538,7 +538,7 @@ function CheckIn({
})
: "";
const checkInEndsString = isMounted
const checkInEndsString = isHydrated
? formatTime(endDate, {
minute: "numeric",
hour: "numeric",
@ -549,7 +549,7 @@ function CheckIn({
if (status === "UPCOMING") {
return (
<div className={clsx("text-center text-xs", { invisible: !isMounted })}>
<div className={clsx("text-center text-xs", { invisible: !isHydrated })}>
{t("tournament:pre.checkIn.range", {
start: checkInStartsString,
finish: checkInEndsString,

View File

@ -14,7 +14,7 @@ import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-con
import { useUser } from "~/features/auth/core/user";
import { useChatContext } from "~/features/chat/useChatContext";
import { Tournament } from "~/features/tournament-bracket/core/Tournament";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useHydrated } from "~/hooks/useHydrated";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { removeMarkdown } from "~/utils/strings";
import {
@ -83,12 +83,12 @@ export const handle: SendouRouteHandle = {
const TournamentContext = React.createContext<Tournament>(null!);
export default function TournamentLayoutShell() {
const isMounted = useIsMounted();
const isHydrated = useHydrated();
// tournaments are something that people like to refresh a lot
// which can cause spikes that are hard for the server to handle
// this is just making sure the SSR for this page is as fast as possible in prod
if (!isMounted)
if (!isHydrated)
return (
<Main bigger>
<Placeholder />

View File

@ -30,7 +30,7 @@ import {
findWidgetById,
} from "~/features/user-page/core/widgets/portfolio";
import { USER } from "~/features/user-page/user-page-constants";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useHydrated } from "~/hooks/useHydrated";
import { action } from "../actions/u.$identifier.edit-widgets.server";
import { WidgetSettingsForm } from "../components/WidgetSettingsForm";
import { loader } from "../loaders/u.$identifier.edit-widgets.server";
@ -41,7 +41,7 @@ export { action, loader };
export default function EditWidgetsPage() {
const { t } = useTranslation(["user", "common"]);
const data = useLoaderData<typeof loader>();
const isMounted = useIsMounted();
const isHydrated = useHydrated();
const fetcher = useFetcher();
const [selectedWidgets, setSelectedWidgets] = useState<
@ -134,7 +134,7 @@ export default function EditWidgetsPage() {
setExpandedWidgetId(expandedWidgetId === widgetId ? null : widgetId);
};
if (!isMounted) {
if (!isHydrated) {
return <Placeholder />;
}

View File

@ -4,7 +4,7 @@ import { FormMessage } from "~/components/FormMessage";
import { FriendCodePopover } from "~/components/FriendCodePopover";
import { BADGE } from "~/features/badges/badges-constants";
import { SendouForm } from "~/form/SendouForm";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useHydrated } from "~/hooks/useHydrated";
import { useHasRole } from "~/modules/permissions/hooks";
import { countryCodeToTranslatedName } from "~/utils/i18n";
import invariant from "~/utils/invariant";
@ -114,11 +114,11 @@ export default function UserEditPage() {
function useCountryOptions() {
const { i18n } = useTranslation();
const isMounted = useIsMounted();
const isHydrated = useHydrated();
return COUNTRY_CODES.map((countryCode) => ({
value: countryCode,
label: isMounted
label: isHydrated
? countryCodeToTranslatedName({
countryCode,
language: i18n.language,

View File

@ -42,7 +42,7 @@ import type {
SeasonTournamentResult,
} from "~/features/sendouq-match/SQMatchRepository.server";
import { useWeaponUsage } from "~/hooks/swr";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useHydrated } from "~/hooks/useHydrated";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { modesShort } from "~/modules/in-game-lists/modes";
import { stageIds } from "~/modules/in-game-lists/stage-ids";
@ -206,7 +206,7 @@ function SeasonHeader({
}) {
const { t } = useTranslation(["user"]);
const { formatDate } = useTimeFormat();
const isMounted = useIsMounted();
const isHydrated = useHydrated();
const { starts, ends } = Seasons.nthToDateRange(seasonViewed);
const navigate = useNavigate();
const options = useSeasonSelectOptions();
@ -237,9 +237,11 @@ function SeasonHeader({
)}
</SendouSelect>
<div
className={clsx("text-sm text-lighter mt-2", { invisible: !isMounted })}
className={clsx("text-sm text-lighter mt-2", {
invisible: !isHydrated,
})}
>
{isMounted ? (
{isHydrated ? (
<>
{formatDate(new Date(starts), {
day: "numeric",
@ -735,7 +737,7 @@ function Results({
seasonViewed: number;
results: UserSeasonsPageLoaderData["results"];
}) {
const isMounted = useIsMounted();
const isHydrated = useHydrated();
const { formatDate } = useTimeFormat();
const [, setSearchParams] = useSearchParams();
const ref = React.useRef<HTMLDivElement>(null);
@ -768,11 +770,11 @@ function Results({
className={clsx(
"text-xs font-semi-bold text-theme-secondary",
{
invisible: !isMounted || !shouldRenderDateHeader,
invisible: !isHydrated || !shouldRenderDateHeader,
},
)}
>
{isMounted
{isHydrated
? formatDate(databaseTimestampToDate(result.createdAt), {
weekday: "long",
month: "long",

View File

@ -11,7 +11,7 @@ import { Image, WeaponImage } from "~/components/Image";
import { Main } from "~/components/Main";
import { YouTubeEmbed } from "~/components/YouTubeEmbed";
import { useUser } from "~/features/auth/core/user";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useHydrated } from "~/hooks/useHydrated";
import { useSearchParamState } from "~/hooks/useSearchParamState";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { shortStageName } from "~/modules/in-game-lists/stage-ids";
@ -80,7 +80,7 @@ export default function VodPage() {
defaultValue: 0,
revive: Number,
});
const isMounted = useIsMounted();
const isHydrated = useHydrated();
const [autoplay, setAutoplay] = React.useState(false);
const data = useLoaderData<typeof loader>();
const { t } = useTranslation(["common", "vods"]);
@ -102,10 +102,10 @@ export default function VodPage() {
<PovUser pov={data.vod.pov} />
<time
className={clsx("text-lighter text-xs", {
invisible: !isMounted,
invisible: !isHydrated,
})}
>
{isMounted
{isHydrated
? formatDate(databaseTimestampToDate(data.vod.youtubeDate), {
day: "numeric",
month: "numeric",

32
app/hooks/useHydrated.ts Normal file
View File

@ -0,0 +1,32 @@
import * as React from "react";
// credits: https://github.com/sergiodxa/remix-utils/blob/main/src/react/use-hydrated.ts
function subscribe() {
return () => {};
}
/**
* Return a boolean indicating if the JS has been hydrated already.
* When doing Server-Side Rendering, the result will always be false.
* When doing Client-Side Rendering, the result will always be false on the
* first render and true from then on. Even if a new component renders it will
* always start with true.
*
* Example: Disable a button that needs JS to work.
* ```tsx
* let hydrated = useHydrated();
* return (
* <button type="button" disabled={!hydrated} onClick={doSomethingCustom}>
* Click me
* </button>
* );
* ```
*/
export function useHydrated() {
return React.useSyncExternalStore(
subscribe,
() => true,
() => false,
);
}

View File

@ -1,11 +0,0 @@
import * as React from "react";
export function useIsMounted() {
const [isMounted, setIsMounted] = React.useState(false);
React.useEffect(() => {
setIsMounted(true);
}, []);
return isMounted;
}

View File

@ -52,7 +52,7 @@ import {
useTheme,
} from "./features/theme/core/provider";
import { getThemeSession } from "./features/theme/core/theme-session.server";
import { useIsMounted } from "./hooks/useIsMounted";
import { useHydrated } from "./hooks/useHydrated";
import { DEFAULT_LANGUAGE } from "./modules/i18n/config";
import { i18nCookie, i18next } from "./modules/i18n/i18next.server";
import { IS_E2E_TEST_RUN } from "./utils/e2e";
@ -416,9 +416,9 @@ export const ErrorBoundary = () => {
};
function HydrationTestIndicator() {
const isMounted = useIsMounted();
const isHydrated = useHydrated();
if (!isMounted) return null;
if (!isHydrated) return null;
return <div style={{ display: "none" }} data-testid="hydrated" />;
}