Vercel/src/components/MoreComponents.tsx
2024-02-12 11:52:54 -06:00

253 lines
6.6 KiB
TypeScript

'use client';
import { useCallback, useEffect, useRef } from 'react';
import Spinner from './Spinner';
import SiteGrid from './SiteGrid';
import {
MoreComponentsKey,
MoreComponentsStateForKeyArgument,
useMoreComponentsState,
} from '@/state/MoreComponentsState';
const MAX_ATTEMPTS_PER_REQUEST = 5;
const MAX_TOTAL_REQUESTS = 100;
const RETRY_DELAY_IN_SECONDS = 1;
export default function MoreComponents({
stateKey,
initialOffset,
itemsPerRequest,
getNextComponent,
label = 'Load more',
triggerOnView = true,
prefetch = true,
itemsClass,
wrapMoreButtonInSiteGrid,
debug,
}: {
stateKey: MoreComponentsKey
initialOffset: number
itemsPerRequest: number
getNextComponent: (offset: number, limit: number) => Promise<{
nextComponent?: JSX.Element,
isFinished?: boolean,
didFail?: boolean,
}>
label?: string
triggerOnView?: boolean
prefetch?: boolean
itemsClass?: string
wrapMoreButtonInSiteGrid?: boolean
debug?: boolean
}) {
const { state, setStateForKey } = useMoreComponentsState();
const setState = useCallback(
(stateForKey: MoreComponentsStateForKeyArgument) =>
setStateForKey(stateKey, stateForKey),
[setStateForKey, stateKey]);
useEffect(() => {
setState({ hasMounted: true });
}, [setState]);
const {
hasMounted,
isLoading,
indexInView,
finalIndex,
didReachMaximumRequests,
components,
} = state[stateKey];
// When prefetching, always stay one request ahead of what's visible
const furthestIndexToLoad = Math.min(
prefetch ? (indexInView ?? 0) + 1 : (indexInView ?? 0),
finalIndex ?? Infinity,
);
const indexToLoad = Math.min(
components.length,
furthestIndexToLoad,
);
const attemptsPerRequest = useRef(0);
const totalRequests = useRef(0);
const hasFinalIndexBeenReached =
finalIndex !== undefined &&
finalIndex <= components.length - 1;
const areAllComponentsVisible =
(indexInView ?? 0) >= components.length - 1;
const showMoreButton =
isLoading ||
!hasFinalIndexBeenReached ||
!areAllComponentsVisible;
const currentTimeout = useRef<NodeJS.Timeout>();
const attempt = useCallback(() => {
// Consider creating temp, anonymous function
// for error handling
const attemptRetry = () => {
if (currentTimeout.current) {
clearTimeout(currentTimeout.current);
}
currentTimeout.current =
setTimeout(attempt, RETRY_DELAY_IN_SECONDS * 1000);
};
if (attemptsPerRequest.current < MAX_ATTEMPTS_PER_REQUEST) {
if (totalRequests.current < MAX_TOTAL_REQUESTS) {
attemptsPerRequest.current += 1;
totalRequests.current += 1;
setState({ isLoading: true });
if (debug) {
// eslint-disable-next-line max-len
console.log(`GETTING INDEX: #${indexToLoad}, ATTEMPT: #${attemptsPerRequest.current}`);
}
getNextComponent(
initialOffset + indexToLoad * itemsPerRequest,
itemsPerRequest,
)
.then(({ nextComponent, isFinished, didFail }) => {
if (!didFail) {
attemptsPerRequest.current = 0;
setState(state => {
const updatedComponents = [...state.components];
if (nextComponent) {
updatedComponents[indexToLoad] = nextComponent;
}
return {
...state,
...nextComponent && { components: updatedComponents},
latestIndexLoaded: indexToLoad,
isLoading: false,
didReachMaximumRequests: false,
...isFinished && {
// Special case when finished on first request
finalIndex: indexToLoad === 0 ? -1 : indexToLoad,
},
};
});
} else {
attemptRetry();
}
})
.catch(attemptRetry);
} else {
console.log(
`Max total attempts reached (${MAX_TOTAL_REQUESTS})`
);
setState({
isLoading: false,
didReachMaximumRequests: true,
});
}
} else {
console.log(
`Max attempts per request reached ${MAX_ATTEMPTS_PER_REQUEST}`
);
setState({
isLoading: false,
didReachMaximumRequests: true,
});
}
}, [
setState,
getNextComponent,
initialOffset,
indexToLoad,
itemsPerRequest,
debug,
]);
useEffect(() => {
if (
!isLoading &&
indexToLoad >= components.length
) {
attempt();
}
}, [isLoading, indexToLoad, attempt, components.length]);
const buttonRef = useRef<HTMLButtonElement>(null);
const resetRequestsAndRetry = useCallback(() => {
attemptsPerRequest.current = 0;
totalRequests.current = 0;
setState({ didReachMaximumRequests: false });
attempt();
}, [attempt, setState]);
const advance = useCallback(() => {
if (indexInView === undefined) {
setState({ indexInView: 0 });
} else if (
indexInView <= components.length - 1 && (
finalIndex === undefined ||
indexInView < finalIndex
)
) {
setState({ indexInView: indexInView + 1});
}
}, [components.length, finalIndex, indexInView, setState]);
useEffect(() => {
// Only add observer if button is rendered
if (buttonRef.current) {
const observer = new IntersectionObserver(e => {
if (triggerOnView && e[0].isIntersecting) {
advance();
}
}, {
root: null,
threshold: 0,
});
observer.observe(buttonRef.current);
return () => observer.disconnect();
}
}, [triggerOnView, advance]);
const renderMoreButton = () =>
<button
ref={buttonRef}
className="block w-full subtle"
onClick={didReachMaximumRequests ? resetRequestsAndRetry : advance}
disabled={isLoading}
>
{isLoading || !hasMounted
? <span className="relative inline-block translate-y-[3px]">
<Spinner size={16} />
</span>
: didReachMaximumRequests
? 'Try again …'
: label}
</button>;
if (debug) {
console.log({
indexInView,
componentsLength: components.length,
finalIndex,
hasFinalIndexBeenReached,
areAllComponentsVisible,
isLoading,
});
}
return <>
<div className="space-y-4">
<div className={itemsClass}>
{components.slice(0, (indexInView ?? 0) + 1)}
</div>
{showMoreButton && (
wrapMoreButtonInSiteGrid
? <SiteGrid contentMain={renderMoreButton()} />
: renderMoreButton()
)}
</div>
</>;
}