diff --git a/src/components/LinkWithStatus.tsx b/src/components/LinkWithStatus.tsx index 879e445c..0df67852 100644 --- a/src/components/LinkWithStatus.tsx +++ b/src/components/LinkWithStatus.tsx @@ -1,94 +1,32 @@ 'use client'; -import { - ComponentProps, - ReactNode, - useCallback, - useEffect, - useRef, - useState, -} from 'react'; +import { ComponentProps, ReactNode, useState } from 'react'; import Link from 'next/link'; -import { usePathname } from 'next/navigation'; +import LinkWithStatusChild from './primitives/LinkWithStatusChild'; import clsx from 'clsx/lite'; -// Avoid showing spinner for too short a time -const FLICKER_THRESHOLD = 400; -// Clear loading status after long duration -const MAX_LOADING_DURATION = 15_000; - export default function LinkWithStatus({ - loadingClassName, - href, - className, - onClick, children, + className, + loadingClassName, isLoading: isLoadingProp = false, setIsLoading: setIsLoadingProp, ...props -}: Omit, 'children'> & { +}: ComponentProps & { children: ReactNode | ((props: { isLoading: boolean }) => ReactNode) loadingClassName?: string // For hoisting state to a parent component, e.g., isLoading?: boolean setIsLoading?: (isLoading: boolean) => void }) { - const path = usePathname(); - - const [pathWhenClicked, setPathWhenClicked] = useState(); const [_isLoading, _setIsLoading] = useState(false); const isLoading = isLoadingProp || _isLoading; const setIsLoading = setIsLoadingProp || _setIsLoading; - - const isLoadingStartTime = useRef(undefined); - - const startLoadingTimeout = useRef(undefined); - const stopLoadingTimeout = useRef(undefined); - const maxLoadingTimeout = useRef(undefined); const isControlled = typeof children === 'function'; - const clearTimeouts = useCallback(() => { - [startLoadingTimeout, stopLoadingTimeout, maxLoadingTimeout] - .forEach(timeout => { - if (timeout.current) { clearTimeout(timeout.current); } - }); - }, []); - - const stopLoading = useCallback(() => { - setIsLoading(false); - setPathWhenClicked(undefined); - }, [setIsLoading]); - - const isVisitingLinkHref = path === href; - - const shouldCancelLoading = - (pathWhenClicked && pathWhenClicked !== path) || - isVisitingLinkHref; - - useEffect(() => { - if (shouldCancelLoading) { - clearTimeouts(); - const loadingDuration = isLoadingStartTime.current - ? Date.now() - isLoadingStartTime.current - : 0; - if (loadingDuration < FLICKER_THRESHOLD) { - stopLoadingTimeout.current = setTimeout( - stopLoading, - FLICKER_THRESHOLD - loadingDuration, - ); - } else { - stopLoading(); - } - } - }, [shouldCancelLoading, clearTimeouts, stopLoading]); - - // Clear timeouts when unmounting - useEffect(() => () => clearTimeouts(), [clearTimeouts]); - return { - const isOpeningNewTab = e.metaKey || e.ctrlKey; - if (!isVisitingLinkHref && !isOpeningNewTab) { - setPathWhenClicked(path); - startLoadingTimeout.current = setTimeout( - () => { - isLoadingStartTime.current = Date.now(); - setIsLoading(true); - }, - FLICKER_THRESHOLD, - ); - maxLoadingTimeout.current = setTimeout( - stopLoading, - MAX_LOADING_DURATION, - ); - } - onClick?.(e); - }} > - {typeof children === 'function' - ? children({ isLoading }) - : children} + + {typeof children === 'function' + ? children({ isLoading }) + : children} + ; } diff --git a/src/components/primitives/LinkWithStatusChild.tsx b/src/components/primitives/LinkWithStatusChild.tsx new file mode 100644 index 00000000..f63ff546 --- /dev/null +++ b/src/components/primitives/LinkWithStatusChild.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { ReactNode, useEffect, useRef } from 'react'; +import { useLinkStatus } from 'next/link'; + +const FLICKER_THRESHOLD = 400; + +export default function LinkWithStatusChild({ + children, + isLoading, + setIsLoading, +}: { + children: ReactNode + isLoading: boolean + setIsLoading: (isLoading: boolean) => void +}) { + const { pending } = useLinkStatus(); + + const startLoadingTimeout = useRef(undefined); + const stopLoadingTimeout = useRef(undefined); + + const isLoadingStartTime = useRef(undefined); + useEffect(() => { + if (isLoading) { + isLoadingStartTime.current = Date.now(); + } else { + isLoadingStartTime.current = undefined; + } + }, [isLoading]); + + useEffect(() => { + if (pending) { + clearTimeout(stopLoadingTimeout.current); + startLoadingTimeout.current = setTimeout(() => { + setIsLoading(true); + }, FLICKER_THRESHOLD); + } else if (isLoadingStartTime.current) { + clearTimeout(startLoadingTimeout.current); + const loadingDuration = Date.now() - isLoadingStartTime.current; + stopLoadingTimeout.current = setTimeout(() => { + setIsLoading(false); + }, FLICKER_THRESHOLD - loadingDuration); + } + }, [pending, setIsLoading]); + + useEffect(() => () => { + clearTimeout(startLoadingTimeout.current); + clearTimeout(stopLoadingTimeout.current); + }, []); + + return <>{children}; +}