diff --git a/.gitignore b/.gitignore index f3e2fa02..27843699 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,4 @@ node_modules/ #misc .direnv shell.nix +.eslintcache diff --git a/.prettierignore b/.prettierignore index 4a38b494..e18f0954 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,3 +2,4 @@ .next/ pnpm-lock.yaml node_modules/ +public/images/ \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js index bd700432..a60e2eaf 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -32,18 +32,22 @@ export default defineConfig([ }, { rules: { + // Show stoppers + '@typescript-eslint/no-unsafe-assignment': 'error', + '@typescript-eslint/no-unsafe-member-access': 'error', + '@typescript-eslint/no-unsafe-argument': 'error', + '@typescript-eslint/no-unsafe-call': 'error', + + // Keep track of 'object-shorthand': 'warn', - '@typescript-eslint/no-unsafe-assignment': 'warn', - '@typescript-eslint/no-unsafe-member-access': 'warn', - '@typescript-eslint/no-unsafe-argument': 'warn', - '@typescript-eslint/no-unsafe-enum-comparison': 'off', - '@typescript-eslint/no-unsafe-call': 'warn', '@typescript-eslint/no-unsafe-return': 'warn', + '@typescript-eslint/no-unsafe-enum-comparison': 'warn', + 'react-hooks/set-state-in-effect': 'warn', // Should go back to (default) error when current set are dealt with 'tailwindcss/no-custom-classname': 'off', - 'react-hooks/set-state-in-effect': 'off', //temp - 'react-hooks/preserve-manual-memoization': 'off', //temp - 'react-hooks/refs': 'off', //temp - 'react-hooks/immutability': 'off', //temp + + // React Compiler rules (for testing) + 'react-hooks/refs': 'warn', + 'react-hooks/immutability': 'warn', }, }, { diff --git a/package.json b/package.json index e4c2673f..107ba9f7 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,11 @@ "overrides": { "@babel/runtime@<7.26.10": ">=7.26.10", "undici@>=6.0.0 <6.21.2": ">=6.21.2" - } + }, + "onlyBuiltDependencies": [ + "esbuild", + "sharp", + "unrs-resolver" + ] } } diff --git a/src/app/account/AccountPage.tsx b/src/app/account/AccountPage.tsx index 641b84f9..94307749 100644 --- a/src/app/account/AccountPage.tsx +++ b/src/app/account/AccountPage.tsx @@ -74,7 +74,7 @@ export function AccountPage() { loggedInUser.data && loggedInUser.data.onboardingStage != OnboardingStage.JOINED ) { - window.location.href = '/volunteer' + window.location.assign('/volunteer') return null } diff --git a/src/app/account/sections/AccountDetailsSection.tsx b/src/app/account/sections/AccountDetailsSection.tsx index 71e597b3..f6090e0c 100644 --- a/src/app/account/sections/AccountDetailsSection.tsx +++ b/src/app/account/sections/AccountDetailsSection.tsx @@ -13,7 +13,7 @@ import { zDiscordUserIsInServerResponse } from '@/contracts/responses' import { useFetch } from '@/util/hooks' import { skipToken, useQuery } from '@tanstack/react-query' import cx from 'classnames' -import { ChangeEvent, FormEvent, useEffect, useState } from 'react' +import { ChangeEvent, FormEvent, useState } from 'react' import { IoClose } from 'react-icons/io5' interface AccountDetailsSectionProps { @@ -35,8 +35,9 @@ export function AccountDetailsSection({ onSave, donorLinkError, onDonorLinkSubmit, -}: AccountDetailsSectionProps) { +}: Readonly) { const { ready, onGet } = useFetch() + const [lastUserData, setLastUserData] = useState(userData) const [updatedUser, setUpdatedUser] = useState(userData) const discordUserId = userData.discordUsers?.[0]?.id ?? null @@ -53,9 +54,10 @@ export function AccountDetailsSection({ : skipToken, }) - useEffect(() => { + if (lastUserData !== userData) { setUpdatedUser(userData) - }, [userData]) + setLastUserData(userData) + } const membershipDeliverableLabels: Record< MembershipDeliverableStatus, @@ -168,10 +170,17 @@ export function AccountDetailsSection({ shirtSize: userData.shirtSize ?? null, }) const userHasDonor = !!userData.donors?.length - - useEffect(() => { - if (!pendingLinkEmail) return - + const [prevUserAddress, setPrevUserAddress] = useState(userData.address) + const [prevUserDonors, setPrevUserDonors] = useState(userData.donors) + + // Possibly deprecated - pendingLinkEmail is only set within this statement, which will never run with it's default value + if ( + pendingLinkEmail && + (userData.address !== prevUserAddress || + userData.donors !== prevUserDonors) + ) { + setPrevUserAddress(userData.address) + setPrevUserDonors(userData.donors) const matchedDonor = userData.donors?.find( (donor) => normalizeEmail(donor.email) === pendingLinkEmail ) @@ -188,7 +197,7 @@ export function AccountDetailsSection({ setShowDonorLinkForm(false) setShowAddressConfirmModal(true) setPendingLinkEmail(null) - }, [pendingLinkEmail, userData.address, userData.donors]) + } const handleChangeDonorEmail = (e: ChangeEvent) => { setDonorLinkForm({ diff --git a/src/app/admin/layout/List.tsx b/src/app/admin/layout/List.tsx index 9db412fc..0ff35247 100644 --- a/src/app/admin/layout/List.tsx +++ b/src/app/admin/layout/List.tsx @@ -482,10 +482,10 @@ function PageSelect({ if (0 <= newPage && newPage <= maxPage) onChange(newPage) else setValue((page + 1).toString()) } - - useEffect(() => { - setValue((page + 1).toString()) - }, [page]) + const pageStr = (page + 1).toString() + if (value != pageStr) { + setValue(pageStr) + } useEffect(() => { if (page < 0) onChange(0) diff --git a/src/app/admin/panels/fundraising/page.tsx b/src/app/admin/panels/fundraising/page.tsx index abc47ac0..9085c835 100644 --- a/src/app/admin/panels/fundraising/page.tsx +++ b/src/app/admin/panels/fundraising/page.tsx @@ -569,7 +569,6 @@ export default function Page() { if (inclusiveRangeMs < oneDayMs) { return } - applyChartViewOverrideRange({ start, end, diff --git a/src/app/admin/panels/fundraising/useFundraisingDashboardController.ts b/src/app/admin/panels/fundraising/useFundraisingDashboardController.ts index 443affc2..19eedad9 100644 --- a/src/app/admin/panels/fundraising/useFundraisingDashboardController.ts +++ b/src/app/admin/panels/fundraising/useFundraisingDashboardController.ts @@ -16,7 +16,7 @@ import { import { SortDirection } from '@/contracts/requests' import { useActblueQueries } from '@/queries' import { keepPreviousData, useQueries, useQuery } from '@tanstack/react-query' -import { useEffect, useMemo, useState } from 'react' +import { useMemo, useState } from 'react' type ChartBarDisplayMode = 'grouped' | 'stacked' @@ -291,16 +291,6 @@ export function useFundraisingDashboardController() { ] ) - useEffect(() => { - setChartViewOverrideRange(null) - }, [startDate, endDate, committedPreset]) - - useEffect(() => { - if (!zoomEnabled) { - setChartViewOverrideRange(null) - } - }, [zoomEnabled]) - const chartBucketQueries = useQueries({ queries: chartBuckets.map((bucket) => ({ queryKey: [ @@ -340,11 +330,12 @@ export function useFundraisingDashboardController() { }) }, [chartBuckets, chartBucketQueries]) - useEffect(() => { - if (!validGranularityModes.includes(granularityMode)) { - setGranularityMode('auto') - } - }, [validGranularityModes, granularityMode]) + if ( + granularityMode != 'auto' && + !validGranularityModes.includes(granularityMode) + ) { + setGranularityMode('auto') + } const selectedRangeLabel = useMemo( () => getSelectedRangeLabel(committedPreset, startDate, endDate), @@ -398,7 +389,6 @@ export function useFundraisingDashboardController() { return } } - setChartViewOverrideRange(range) } diff --git a/src/app/admin/panels/members/panel_views/DonorView.tsx b/src/app/admin/panels/members/panel_views/DonorView.tsx index 00db6d31..c828db67 100644 --- a/src/app/admin/panels/members/panel_views/DonorView.tsx +++ b/src/app/admin/panels/members/panel_views/DonorView.tsx @@ -21,7 +21,7 @@ import type { UseQueryResult } from '@tanstack/react-query' import cx from 'classnames' import { motion } from 'motion/react' import Link from 'next/link' -import React, { ChangeEvent, useEffect, useMemo, useState } from 'react' +import React, { ChangeEvent, useMemo, useState } from 'react' export interface DonorViewProps { selectedId: number @@ -144,15 +144,12 @@ export function DonorView({ const [overlayMounted, setOverlayMounted] = useState(false) const [overlayOpen, setOverlayOpen] = useState(false) - - useEffect(() => { - if (pickingDonor) { - setOverlayMounted(true) - requestAnimationFrame(() => setOverlayOpen(true)) - } else if (overlayMounted) { - setOverlayOpen(false) - } - }, [pickingDonor, overlayMounted]) + if (pickingDonor) { + setOverlayMounted(true) + requestAnimationFrame(() => setOverlayOpen(true)) + } else if (overlayMounted) { + setOverlayOpen(false) + } const backdropVariants = { open: { diff --git a/src/app/endorsements/components/FilterButtonRow.tsx b/src/app/endorsements/components/FilterButtonRow.tsx index 0d86d903..b5b3cd99 100644 --- a/src/app/endorsements/components/FilterButtonRow.tsx +++ b/src/app/endorsements/components/FilterButtonRow.tsx @@ -187,6 +187,12 @@ export function FilterButtonRow({ }) }) } + if (!mobile && prev) { + // If we're transitioning from mobile to non-mobile, reset the layout + setMobileSearchWidth(null) + setSingleColumnLayout(false) + setShowSearchLabel(false) + } return mobile }) if (mobile) { @@ -207,9 +213,6 @@ export function FilterButtonRow({ useLayoutEffect(() => { if (!isMobile) { - setMobileSearchWidth(null) - setSingleColumnLayout(false) - setShowSearchLabel(false) return } diff --git a/src/app/home/MemberBanner.tsx b/src/app/home/MemberBanner.tsx index cdfee3c7..9b3097e3 100644 --- a/src/app/home/MemberBanner.tsx +++ b/src/app/home/MemberBanner.tsx @@ -168,7 +168,7 @@ export function safeLogError(err: unknown, prefix = '') { export function MemberBanner() { const { inView, observe } = useInView() const containerRef = useRef(null) - const [visible, setVisible] = useState(false) + const [latchVisible, setLatchVisible] = useState(false) const loggedInUser = useCurrentUser() const discordId = loggedInUser?.data?.discordUsers?.[0]?.id ?? null @@ -184,13 +184,9 @@ export function MemberBanner() { } }, [observe]) - useEffect(() => { - try { - if (inView) setVisible(true) - } catch (err) { - safeLogError(err, 'inView effect error:') - } - }, [inView]) + if (!latchVisible && inView) { + setLatchVisible(true) + } return (
@@ -206,7 +202,7 @@ export function MemberBanner() {
@@ -225,7 +221,7 @@ export function MemberBanner() {
- +
diff --git a/src/app/home/Volunteer.tsx b/src/app/home/Volunteer.tsx index fe1cf3be..ab601ccb 100644 --- a/src/app/home/Volunteer.tsx +++ b/src/app/home/Volunteer.tsx @@ -3,7 +3,7 @@ import styles from './volunteer.module.css' import { motion } from 'motion/react' import Image from 'next/image' -import { useState, useEffect, useRef } from 'react' +import { useEffect, useRef, useState } from 'react' const actions = [ { @@ -35,9 +35,7 @@ export function Volunteer() { if (divRef.current) observe(divRef.current) }, [observe]) - useEffect(() => { - if (inView) setVisible(true) - }, [inView]) + if (!visible && inView) setVisible(true) return (
diff --git a/src/app/login/LoginCard.tsx b/src/app/login/LoginCard.tsx index e3e6a51b..3a52a07e 100644 --- a/src/app/login/LoginCard.tsx +++ b/src/app/login/LoginCard.tsx @@ -3,6 +3,7 @@ import { useAuth } from '@/util/hooks' import Image from 'next/image' import { useSearchParams } from 'next/navigation' +import { useEffect } from 'react' export function LoginCard() { const { session, onLogin } = useAuth() @@ -19,10 +20,11 @@ export function LoginCard() { ? 'An unknown error occurred. Please try again later.' : null - if (session) { - window.location.href = redirect - return null - } + useEffect(() => { + if (session) { + window.location.href = redirect + } + }, [session, redirect]) return (
diff --git a/src/app/volunteer/CollectInfoStage.tsx b/src/app/volunteer/CollectInfoStage.tsx index 75e14249..aee66be7 100644 --- a/src/app/volunteer/CollectInfoStage.tsx +++ b/src/app/volunteer/CollectInfoStage.tsx @@ -5,7 +5,7 @@ import { useFetch, useInit } from '@/util/hooks' import Link from 'next/link' import phone from 'phone' import { Country, isValidCountryPostalCode } from 'postal-code-validator' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' export interface IOnboardingForm { firstName: string @@ -50,12 +50,19 @@ export function CollectInfoStage({ ) const [zipCodeError, setZipCodeError] = useState(false) - const parsePhone = (number: string) => - phone(number, { + const parsePhone = (number: string) => { + const p = phone(number, { country: 'US', strictDetection: true, validateMobilePrefix: true, }) + if (p.isValid) + setForm((f) => ({ + ...f, + phoneNumber: number.substring(2), + })) + return p + } const parsedPhone = useMemo(() => parsePhone(phoneNumber), [phoneNumber]) const setFormattedPhoneNumber = (number: string) => { @@ -99,14 +106,6 @@ export function CollectInfoStage({ useInit(() => setFormattedPhoneNumber(initialForm.phoneNumber)) - useEffect(() => { - if (parsedPhone.isValid) - setForm((f) => ({ - ...f, - phoneNumber: parsedPhone.phoneNumber.substring(2), - })) - }, [parsedPhone]) - return (
diff --git a/src/app/volunteer/PhoneVerifyStage.tsx b/src/app/volunteer/PhoneVerifyStage.tsx index d24727a5..c60b2cb5 100644 --- a/src/app/volunteer/PhoneVerifyStage.tsx +++ b/src/app/volunteer/PhoneVerifyStage.tsx @@ -33,7 +33,7 @@ export function PhoneVerifyStage({ }, [lastSmsCodeSendTimeUtc]) const [securityCode, setSecurityCode] = useState('') - const [codeTimer, setCodeTimer] = useState(0) + const [codeTimer, setCodeTimer] = useState(getCodeTime()) const handleChange = (e: ChangeEvent) => { const value = e.target.value @@ -48,7 +48,6 @@ export function PhoneVerifyStage({ } useEffect(() => { - setCodeTimer(getCodeTime()) const interval = setInterval(() => { setCodeTimer(getCodeTime()) }, 200) diff --git a/src/app/volunteer/VolunteerPage.tsx b/src/app/volunteer/VolunteerPage.tsx index c079cd0e..282f8754 100644 --- a/src/app/volunteer/VolunteerPage.tsx +++ b/src/app/volunteer/VolunteerPage.tsx @@ -25,7 +25,7 @@ import { useQuery, useQueryClient, } from '@tanstack/react-query' -import { useEffect, useState } from 'react' +import { useState } from 'react' export default function VolunteerPage() { const queryClient = useQueryClient() @@ -159,23 +159,21 @@ export default function VolunteerPage() { const currentStage = overrideStage ?? user.data?.onboardingStage - useEffect(() => { - if ( - currentStage === OnboardingStage.JOINED && - (!user.data?.firstName || - !user.data?.lastName || - !user.data?.birthdate || - !user.data?.address?.zip || - !user.data?.phone) - ) { - setOverrideStage(OnboardingStage.NOT_STARTED) - } - }, [user.data, currentStage]) + if ( + currentStage === OnboardingStage.JOINED && + (!user.data?.firstName || + !user.data?.lastName || + !user.data?.birthdate || + !user.data?.address?.zip || + !user.data?.phone) + ) { + setOverrideStage(OnboardingStage.NOT_STARTED) + } if (isSessionLoading) return null if (!session) { - window.location.href = '/login?redirect=/volunteer' + window.location.replace('/login?redirect=/volunteer') return null } diff --git a/src/components/common/ImageWithFallback.tsx b/src/components/common/ImageWithFallback.tsx index e4c2fed8..e707bf0a 100644 --- a/src/components/common/ImageWithFallback.tsx +++ b/src/components/common/ImageWithFallback.tsx @@ -2,7 +2,7 @@ import cx from 'classnames' import Image from 'next/image' -import { useEffect, useState } from 'react' +import { useState } from 'react' export interface ImageWithFallbackProps { src: string @@ -24,25 +24,21 @@ export function ImageWithFallback({ className, loading = 'lazy', priority = false, -}: ImageWithFallbackProps) { - const [hasErrored, setHasErrored] = useState(false) +}: Readonly) { + const [erroredSrc, setErroredSrc] = useState(null) - useEffect(() => { - setHasErrored(false) - }, [src, useFallback]) + const fallbackSrc = + 'https://dummyjson.com/image/100x100/e8e0e0/d0c8c8?text=!&fontFamily=Poppins' + const finalSrc = useFallback || erroredSrc === src ? fallbackSrc : src return ( {alt} setHasErrored(true)} + onError={() => setErroredSrc(src)} loading={priority ? undefined : loading} priority={priority} /> diff --git a/src/components/common/ToolTip.tsx b/src/components/common/ToolTip.tsx index c61665d9..53c159b0 100644 --- a/src/components/common/ToolTip.tsx +++ b/src/components/common/ToolTip.tsx @@ -1,4 +1,5 @@ -import { useState, useEffect, useRef, RefObject } from 'react' +import { useOutsideDetector } from '@/util/hooks' +import { useEffect, useRef, useState } from 'react' /* preliminary notes: component that generates a button which upon being clicked displays an @@ -25,24 +26,6 @@ export function ToolTip({ const [closerList, setCloserList] = useState([]) const wrapperRef = useRef(null) - function useOutsideDetector(ref: RefObject) { - useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if ( - ref.current && - !ref.current.contains(event.target as HTMLElement) - ) { - setOpen(false) - } - } - - document.addEventListener('mousedown', handleClickOutside) - return () => { - document.removeEventListener('mousedown', handleClickOutside) - } - }, [ref]) - } - useEffect(() => { if (!wrapperRef.current) return const closers = Array.from( @@ -52,7 +35,7 @@ export function ToolTip({ setCloserList(closers) }, [open]) - useOutsideDetector(wrapperRef) + useOutsideDetector(wrapperRef, setOpen) closerList?.map((element: Element) => { if (element instanceof HTMLButtonElement) { diff --git a/src/components/common/charts/DualAxisBarLineChart.tsx b/src/components/common/charts/DualAxisBarLineChart.tsx index d018d936..86099356 100644 --- a/src/components/common/charts/DualAxisBarLineChart.tsx +++ b/src/components/common/charts/DualAxisBarLineChart.tsx @@ -237,12 +237,13 @@ export function Chart({ } }, []) + if (tooltipPos && (hoverIdx == null || !hoverPos)) { + setTooltipPos(null) + } useLayoutEffect(() => { if (hoverIdx == null || !hoverPos) { - setTooltipPos(null) return } - const wrapNode = wrapRef.current const tooltipNode = tooltipRef.current if (!wrapNode || !tooltipNode) return diff --git a/src/components/common/forms/FormField.tsx b/src/components/common/forms/FormField.tsx index babf800a..b4e60579 100644 --- a/src/components/common/forms/FormField.tsx +++ b/src/components/common/forms/FormField.tsx @@ -1,5 +1,5 @@ import styles from './FormField.module.css' -import { ReactElement, useEffect, useMemo } from 'react' +import { ReactElement, useCallback, useEffect, useMemo } from 'react' /** * Properties for the FormField component. All form fields extend their @@ -165,34 +165,41 @@ export function useConfigure( const id = props.id const onConfigure = props.dynamic?.onConfigure + // Props object changing will re-trigger Callback, so we need to grab the values beforehand + // So that we only regenerate on change + const propGetter = props.getter + const propSetter = props.setter + const propField = props.field // Use the provided getter if available, or default to `form[props.field]`. // If no field is provided either, be sad and return undefined. - const getter = useMemo(() => { - const getter = props.getter - if (getter) return getter + const getter = useCallback( + (form: FormType): FieldType | undefined => { + if (propGetter) return propGetter(form) - const key = props.field - if (key) - return (form: FormType) => (form as Record)[key] - - return () => undefined - }, [props.getter, props.field]) + const key = propField + if (!key) return undefined + return (form as Record)[key] + }, + [propGetter, propField] + ) // Use the provided setter if available, or default to `form[props.field]`. // If no field is provided either, be sad and return undefined. - const setter = useMemo(() => { - const setter = props.setter - if (setter) return setter - const key = props.field - if (key) - return (form: FormType, field: FieldType) => ({ + const setter = useCallback( + (form: FormType, field: FieldType): FormType => { + if (propSetter) return propSetter(form, field) + + const key = propField + if (!key) return form + + return { ...form, [key]: field, - }) - - return (form: FormType) => form - }, [props.setter, props.field]) + } + }, + [propSetter, propField] + ) // Use the provided validator if available, or default to the, well, // default one. We can't be sad here because it can't be undefined. diff --git a/src/components/common/mini_calendar/DateRangePicker.tsx b/src/components/common/mini_calendar/DateRangePicker.tsx index bde0dfd6..239a9eb4 100644 --- a/src/components/common/mini_calendar/DateRangePicker.tsx +++ b/src/components/common/mini_calendar/DateRangePicker.tsx @@ -10,7 +10,7 @@ import { startOfDayISO, } from './dateRange.helpers' import { useDateRangeSelectionController } from './useDateRangeSelectionController' -import { useEffect, useMemo, useState } from 'react' +import { useMemo, useState } from 'react' interface DateRangePickerProps { startDate: Date | null @@ -39,13 +39,17 @@ export function DateRangePicker({ const anchor = startDate ? new Date(startDate) : new Date() return new Date(anchor.getFullYear(), anchor.getMonth(), 1) }) + const [prevDate, setPrevDate] = useState(startDate) - useEffect(() => { - if (!startDate) return + if (startDate && startDate != prevDate) { + setPrevDate(startDate) const anchor = new Date(startDate) - if (Number.isNaN(anchor.getTime())) return - setCalendarMonth(new Date(anchor.getFullYear(), anchor.getMonth(), 1)) - }, [startDate]) + if (!Number.isNaN(anchor.getTime())) { + setCalendarMonth( + new Date(anchor.getFullYear(), anchor.getMonth(), 1) + ) + } + } const todayInputValue = useMemo( () => isoToDateInput(new Date().toISOString()), diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index a64d421f..85d7e741 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -616,6 +616,11 @@ export function Header() { null ) + const handleClose = () => { + setIsOpen(false) + setMobileSubnavItem(null) + } + const { data: discordUsers } = useQuery({ queryKey: [`/discordUsers/${session?.userId}`], queryFn: @@ -664,7 +669,7 @@ export function Header() { if (!isOpen) return const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') setIsOpen(false) + if (e.key === 'Escape') handleClose() } document.addEventListener('keydown', onKeyDown) @@ -685,10 +690,6 @@ export function Header() { return () => document.removeEventListener('keydown', onKeyDown) }, []) - useEffect(() => { - if (!isOpen) setMobileSubnavItem(null) - }, [isOpen]) - useEffect(() => { if (typeof window === 'undefined') return @@ -696,7 +697,7 @@ export function Header() { const syncToBreakpoint = () => { if (desktopMQ.matches) { - setIsOpen(false) + handleClose() } else { setActiveSubnav(null) } @@ -968,7 +969,7 @@ export function Header() { WebkitBackdropFilter: 'blur(10px)', backgroundColor: 'rgba(0, 0, 0, 0.18)', }} - onClick={() => setIsOpen(false)} + onClick={handleClose} /> )} diff --git a/src/util/hooks/index.ts b/src/util/hooks/index.ts index 5b4823e0..81296fcf 100644 --- a/src/util/hooks/index.ts +++ b/src/util/hooks/index.ts @@ -7,5 +7,6 @@ export * from './useHydration' export * from './useInit' export * from './useInView' export * from './useOptimisticMutation' +export * from './useOutsideDetector' export * from './usePaginatedSearch' export * from './useUnpaginatedSearch' diff --git a/src/util/hooks/useHydration.ts b/src/util/hooks/useHydration.ts index 62e58610..8ab97d51 100644 --- a/src/util/hooks/useHydration.ts +++ b/src/util/hooks/useHydration.ts @@ -1,11 +1,9 @@ -import { useEffect, useState } from 'react' +import { useSyncExternalStore } from 'react' export function useHydration() { - const [hydrated, setHydrated] = useState(false) - - useEffect(() => { - setHydrated(true) - }, []) - - return hydrated + return useSyncExternalStore( + () => () => null, + () => true, + () => false + ) } diff --git a/src/util/hooks/useOutsideDetector.ts b/src/util/hooks/useOutsideDetector.ts new file mode 100644 index 00000000..e295077f --- /dev/null +++ b/src/util/hooks/useOutsideDetector.ts @@ -0,0 +1,22 @@ +import { RefObject, useEffect } from 'react' + +export function useOutsideDetector( + ref: RefObject, + setOpen: (open: boolean) => void +) { + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if ( + ref.current && + !ref.current.contains(event.target as HTMLElement) + ) { + setOpen(false) + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } + }, [ref, setOpen]) +}