Refactor <MoreComponents /> fetching/error handling behavior

This commit is contained in:
Sam Becker 2024-02-11 22:42:07 -06:00
parent 9810514c76
commit e034e3766b
9 changed files with 263 additions and 101 deletions

View File

@ -55,8 +55,6 @@ export default async function GridPage() {
</div>}
sideHiddenOnMobile
/>
: <Suspense>
<PhotosEmptyState />
</Suspense>
: <PhotosEmptyState />
);
}

View File

@ -96,8 +96,8 @@ export default function RootLayout({
</ThemeProviderClient>
</MoreComponentsProvider>
</AppStateProvider>
<Analytics />
<SpeedInsights />
<Analytics debug={false} />
<SpeedInsights debug={false} />
<PhotoEscapeHandler />
<ToasterWithThemes />
</body>

View File

@ -0,0 +1,125 @@
'use client';
import { Children, ReactNode, useRef } from 'react';
import { Variant, motion } from 'framer-motion';
import { useAppState } from '@/state/AppState';
import usePrefersReducedMotion from '@/utility/usePrefersReducedMotion';
export type AnimationType = 'none' | 'scale' | 'left' | 'right' | 'bottom';
export interface AnimationConfig {
type?: AnimationType
duration?: number
staggerDelay?: number
scaleOffset?: number
distanceOffset?: number
}
interface Props extends AnimationConfig {
children: ReactNode
className?: string
classNameItem?: string
animateFromAppState?: boolean
animateOnFirstLoadOnly?: boolean
staggerOnFirstLoadOnly?: boolean
}
export default function AnimateChildren({
children,
className,
classNameItem,
type = 'scale',
duration = 0.6,
staggerDelay = 0.1,
scaleOffset = 0.9,
distanceOffset = 20,
animateFromAppState,
animateOnFirstLoadOnly,
staggerOnFirstLoadOnly,
}: Props) {
const {
hasLoaded,
nextPhotoAnimation,
clearNextPhotoAnimation,
} = useAppState();
const prefersReducedMotion = usePrefersReducedMotion();
const hasLoadedInitial = useRef(hasLoaded);
const nextPhotoAnimationInitial = useRef(nextPhotoAnimation);
const shouldAnimate = type !== 'none' &&
!prefersReducedMotion &&
!(animateOnFirstLoadOnly && hasLoadedInitial.current);
const shouldStagger =
!(staggerOnFirstLoadOnly && hasLoadedInitial.current);
const typeResolved = animateFromAppState
? (nextPhotoAnimationInitial.current?.type ?? type)
: type;
const durationResolved = animateFromAppState
? (nextPhotoAnimationInitial.current?.duration ?? duration)
: duration;
const getInitialVariant = (): Variant => {
switch (typeResolved) {
case 'left': return {
opacity: 0,
transform: `translateX(${distanceOffset}px)`,
};
case 'right': return {
opacity: 0,
transform: `translateX(${-distanceOffset}px)`,
};
case 'bottom': return {
opacity: 0,
transform: `translateY(${distanceOffset}px)`,
};
default: return {
opacity: 0,
transform: `translateY(${distanceOffset}px) scale(${scaleOffset})`,
};
}
};
return (
<motion.div
className={className}
initial={shouldAnimate ? 'hidden' : false}
animate="show"
variants={shouldStagger
? {
show: {
transition: {
staggerChildren: staggerDelay,
},
},
} : undefined}
onAnimationComplete={() => {
if (animateFromAppState) {
clearNextPhotoAnimation?.();
}
}}
>
{Children.toArray(children).map((item, index) =>
<motion.div
key={index}
className={classNameItem}
variants={{
hidden: getInitialVariant(),
show: {
opacity: 1,
transform: 'translateX(0) translateY(0) scale(1)',
},
}}
transition={{
duration: durationResolved,
easing: 'easeOut',
}}
>
{item}
</motion.div>)}
</motion.div>
);
};

View File

@ -1,10 +1,11 @@
import { clsx } from 'clsx/lite';
import { ReactNode } from 'react';
import { BiErrorAlt } from 'react-icons/bi';
export default function ErrorNote({
children,
}: {
children: React.ReactNode
children: ReactNode
}) {
return (
<div className={clsx(

View File

@ -10,8 +10,8 @@ import {
} from '@/state/MoreComponentsState';
const MAX_ATTEMPTS_PER_REQUEST = 5;
const MAX_TOTAL_REQUESTS = 500;
const RETRY_DELAY_IN_SECONDS = 1.5;
const MAX_TOTAL_REQUESTS = 100;
const RETRY_DELAY_IN_SECONDS = 1;
export default function MoreComponents({
stateKey,
@ -22,6 +22,7 @@ export default function MoreComponents({
triggerOnView = true,
prefetch = true,
wrapMoreButtonInSiteGrid,
debug,
}: {
stateKey: MoreComponentsKey
initialOffset: number
@ -35,6 +36,7 @@ export default function MoreComponents({
triggerOnView?: boolean
prefetch?: boolean
wrapMoreButtonInSiteGrid?: boolean
debug?: boolean
}) {
const { state, setStateForKey } = useMoreComponentsState();
@ -44,57 +46,72 @@ export default function MoreComponents({
[setStateForKey, stateKey]);
const {
indexToView,
indexLoaded,
isLoading,
lastIndexToLoad,
indexInView,
finalIndex,
didReachMaximumRequests,
components,
} = state[stateKey];
// When prefetching, always stay one request ahead of what's visible
const indexToLoad = lastIndexToLoad
?? (prefetch ? indexToView + 1 : indexToView);
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 showMoreButton = (
lastIndexToLoad === undefined ||
lastIndexToLoad > indexToView
) && (
attemptsPerRequest.current < MAX_ATTEMPTS_PER_REQUEST &&
totalRequests.current < MAX_TOTAL_REQUESTS
);
const showMoreButton =
isLoading ||
finalIndex === undefined ||
finalIndex >= components.length;
const currentTimeout = useRef<NodeJS.Timeout>();
const attempt = useCallback(() => {
const handleError = () => {
setTimeout(() => {
attempt();
}, RETRY_DELAY_IN_SECONDS * 1000);
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 - 1) * itemsPerRequest,
initialOffset + indexToLoad * itemsPerRequest,
itemsPerRequest,
)
.then(({ nextComponent, isFinished, didFail }) => {
if (!didFail && nextComponent) {
if (!didFail) {
attemptsPerRequest.current = 0;
setState(state => {
const updatedComponents = [...state.components];
updatedComponents[indexToLoad] = nextComponent;
if (nextComponent) {
updatedComponents[indexToLoad] = nextComponent;
}
return {
...state,
components: updatedComponents,
indexLoaded: indexToLoad,
...nextComponent && { components: updatedComponents},
latestIndexLoaded: indexToLoad,
isLoading: false,
...isFinished && { lastIndexToLoad: indexToLoad },
didReachMaximumRequests: false,
...isFinished && { finalIndex: indexToLoad },
};
});
attemptsPerRequest.current = 0;
} else {
handleError();
}
@ -104,7 +121,10 @@ export default function MoreComponents({
console.log(
`Max total attempts reached (${MAX_TOTAL_REQUESTS})`
);
setState({ isLoading: false });
setState({
isLoading: false,
didReachMaximumRequests: true,
});
}
} else {
console.log(
@ -112,7 +132,7 @@ export default function MoreComponents({
);
setState({
isLoading: false,
haveAttemptsPerRequestBeenExceeded: true,
didReachMaximumRequests: true,
});
}
}, [
@ -121,32 +141,37 @@ export default function MoreComponents({
initialOffset,
indexToLoad,
itemsPerRequest,
debug,
]);
useEffect(() => {
if (
!isLoading &&
indexToLoad >= indexToView &&
indexToLoad > indexLoaded
indexToLoad >= components.length
) {
console.log('Attempting', { isLoading });
attempt();
}
}, [
isLoading,
indexToLoad,
indexToView,
indexLoaded,
attempt,
]);
}, [isLoading, indexToLoad, indexInView, 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 (indexToView <= indexLoaded) {
setState({ indexToView: indexToView + 1 });
if (indexInView === undefined) {
setState({ indexInView: 0 });
} else if (
(indexInView <= components.length) &&
(finalIndex === undefined || indexInView < finalIndex)
) {
setState({ indexInView: indexInView + 1});
}
}, [setState, indexToView, indexLoaded]);
}, [components.length, finalIndex, indexInView, setState]);
useEffect(() => {
// Only add observer if button is rendered
@ -168,22 +193,26 @@ export default function MoreComponents({
<button
ref={buttonRef}
className="block w-full subtle"
onClick={!triggerOnView ? advance : undefined}
disabled={triggerOnView || isLoading}
onClick={didReachMaximumRequests ? resetRequestsAndRetry : advance}
disabled={isLoading}
>
{isLoading || triggerOnView
{isLoading
? <span className="relative inline-block translate-y-[3px]">
<Spinner size={16} />
</span>
: label}
: didReachMaximumRequests
? 'Try again …'
: label}
</button>;
return <>
<div className="space-y-4">
<div>{components.slice(0, indexToView + 1)}</div>
{(showMoreButton || true) && wrapMoreButtonInSiteGrid
? <SiteGrid contentMain={renderMoreButton()} />
: renderMoreButton()}
<div>{components.slice(0, (indexInView ?? 0) + 1)}</div>
{showMoreButton && (
wrapMoreButtonInSiteGrid
? <SiteGrid contentMain={renderMoreButton()} />
: renderMoreButton()
)}
</div>
</>;
}

View File

@ -1,6 +1,7 @@
import MoreComponents from '@/components/MoreComponents';
import { getPhotosCached } from '@/photo/cache';
import PhotoGrid from './PhotoGrid';
import { useCallback } from 'react';
export function MorePhotosGrid({
initialOffset,
@ -11,32 +12,38 @@ export function MorePhotosGrid({
itemsPerRequest: number
totalPhotosCount: number
}) {
const getNextComponent = useCallback(async (
offset: number,
limit: number,
) => {
'use server';
if (
process.env.NODE_ENV === 'development' &&
Math.random() < 0.5
) {
return { didFail: true };
}
const photos = await getPhotosCached({ limit: offset + limit })
.catch(() => undefined);
if (!photos) {
return { didFail: true };
} else {
const nextPhotos = photos.slice(offset);
return {
...nextPhotos.length > 0 && {
nextComponent: <PhotoGrid photos={nextPhotos} />,
},
isFinished: offset + limit >= totalPhotosCount,
};
}
}, [totalPhotosCount]);
return (
<MoreComponents
stateKey="PhotosGrid"
label="More photos"
initialOffset={initialOffset}
itemsPerRequest={itemsPerRequest}
getNextComponent={async (offset, limit) => {
'use server';
if (
process.env.NODE_ENV === 'development' &&
Math.random() < 0.95
) {
return { didFail: true };
}
const photos = await getPhotosCached({ limit: offset + limit })
.catch(() => undefined);
if (!photos) {
return { didFail: true };
} else {
const nextPhotos = photos.slice(offset);
return {
nextComponent: <PhotoGrid photos={nextPhotos} />,
isFinished: offset + limit >= totalPhotosCount,
};
}
}}
getNextComponent={getNextComponent}
/>
);
}

View File

@ -1,6 +1,7 @@
import MoreComponents from '@/components/MoreComponents';
import PhotosLarge from './PhotosLarge';
import { getPhotosCached } from '@/photo/cache';
import { useCallback } from 'react';
export function MorePhotosRoot({
initialOffset,
@ -11,32 +12,36 @@ export function MorePhotosRoot({
itemsPerRequest: number
totalPhotosCount: number
}) {
const getNextComponent = useCallback(async (
offset: number,
limit: number,
) => {
'use server';
if (
process.env.NODE_ENV === 'development' &&
Math.random() < 0.5
) {
return { didFail: true };
}
const photos = await getPhotosCached({ limit: offset + limit })
.catch(() => undefined);
if (!photos) {
return { didFail: true };
} else {
const nextPhotos = photos.slice(offset);
return {
nextComponent: <PhotosLarge photos={nextPhotos} />,
isFinished: offset + limit >= totalPhotosCount,
};
}
}, [totalPhotosCount]);
return (
<MoreComponents
stateKey="PhotosRoot"
label="More photos"
initialOffset={initialOffset}
itemsPerRequest={itemsPerRequest}
getNextComponent={async (offset, limit) => {
'use server';
if (
process.env.NODE_ENV === 'development' &&
Math.random() < 0.95
) {
return { didFail: true };
}
const photos = await getPhotosCached({ limit: offset + limit })
.catch(() => undefined);
if (!photos) {
return { didFail: true };
} else {
const nextPhotos = photos.slice(offset);
return {
nextComponent: <PhotosLarge photos={nextPhotos} />,
isFinished: offset + limit >= totalPhotosCount,
};
}
}}
getNextComponent={getNextComponent}
wrapMoreButtonInSiteGrid
/>
);

View File

@ -17,7 +17,7 @@ export const GENERATE_STATIC_PARAMS_LIMIT = 1000;
export const INFINITE_SCROLL_MULTIPLE_ROOT =
process.env.NODE_ENV === 'development' ? 2 : 12;
export const INFINITE_SCROLL_MULTIPLE_GRID = HIGH_DENSITY_GRID
? process.env.NODE_ENV === 'development' ? 5 : 20
? process.env.NODE_ENV === 'development' ? 4 : 20
: process.env.NODE_ENV === 'development' ? 4 : 24;
export const GRID_THUMBNAILS_TO_SHOW_MAX = 12;

View File

@ -5,20 +5,17 @@ export type MoreComponentsKey =
'PhotosGrid';
export interface MoreComponentsStateForKey {
indexToView: number
indexLoaded: number
isLoading: boolean
lastIndexToLoad?: number
haveAttemptsPerRequestBeenExceeded: boolean
indexInView?: number
finalIndex?: number
didReachMaximumRequests: boolean
components: JSX.Element[]
}
export const createInitialStateForKey =
(): MoreComponentsStateForKey => ({
indexToView: 0,
indexLoaded: 0,
isLoading: false,
haveAttemptsPerRequestBeenExceeded: false,
didReachMaximumRequests: false,
components: [],
});