Leverage useLinkStatus

This commit is contained in:
Sam Becker 2025-04-12 00:21:28 -05:00
parent 21f8392cc6
commit df1f16f930
2 changed files with 62 additions and 88 deletions

View File

@ -1,94 +1,32 @@
'use client'; 'use client';
import { import { ComponentProps, ReactNode, useState } from 'react';
ComponentProps,
ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import LinkWithStatusChild from './primitives/LinkWithStatusChild';
import clsx from 'clsx/lite'; 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({ export default function LinkWithStatus({
loadingClassName,
href,
className,
onClick,
children, children,
className,
loadingClassName,
isLoading: isLoadingProp = false, isLoading: isLoadingProp = false,
setIsLoading: setIsLoadingProp, setIsLoading: setIsLoadingProp,
...props ...props
}: Omit<ComponentProps<typeof Link>, 'children'> & { }: ComponentProps<typeof Link> & {
children: ReactNode | ((props: { isLoading: boolean }) => ReactNode) children: ReactNode | ((props: { isLoading: boolean }) => ReactNode)
loadingClassName?: string loadingClassName?: string
// For hoisting state to a parent component, e.g., <EntityLink /> // For hoisting state to a parent component, e.g., <EntityLink />
isLoading?: boolean isLoading?: boolean
setIsLoading?: (isLoading: boolean) => void setIsLoading?: (isLoading: boolean) => void
}) { }) {
const path = usePathname();
const [pathWhenClicked, setPathWhenClicked] = useState<string>();
const [_isLoading, _setIsLoading] = useState(false); const [_isLoading, _setIsLoading] = useState(false);
const isLoading = isLoadingProp || _isLoading; const isLoading = isLoadingProp || _isLoading;
const setIsLoading = setIsLoadingProp || _setIsLoading; const setIsLoading = setIsLoadingProp || _setIsLoading;
const isLoadingStartTime = useRef<number | undefined>(undefined);
const startLoadingTimeout = useRef<NodeJS.Timeout | undefined>(undefined);
const stopLoadingTimeout = useRef<NodeJS.Timeout | undefined>(undefined);
const maxLoadingTimeout = useRef<NodeJS.Timeout | undefined>(undefined);
const isControlled = typeof children === 'function'; 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 <Link return <Link
{...props} {...props}
href={href}
className={clsx( className={clsx(
'transition-[colors,opacity]', 'transition-[colors,opacity]',
(loadingClassName || isControlled) (loadingClassName || isControlled)
@ -97,27 +35,11 @@ export default function LinkWithStatus({
className, className,
isLoading && loadingClassName, isLoading && loadingClassName,
)} )}
onClick={e => {
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' <LinkWithStatusChild {...{ isLoading, setIsLoading }}>
? children({ isLoading }) {typeof children === 'function'
: children} ? children({ isLoading })
: children}
</LinkWithStatusChild>
</Link>; </Link>;
} }

View File

@ -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<NodeJS.Timeout>(undefined);
const stopLoadingTimeout = useRef<NodeJS.Timeout>(undefined);
const isLoadingStartTime = useRef<number>(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}</>;
}