Leverage useLinkStatus
This commit is contained in:
parent
21f8392cc6
commit
df1f16f930
@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
52
src/components/primitives/LinkWithStatusChild.tsx
Normal file
52
src/components/primitives/LinkWithStatusChild.tsx
Normal 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}</>;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user