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>} </div>}
sideHiddenOnMobile sideHiddenOnMobile
/> />
: <Suspense> : <PhotosEmptyState />
<PhotosEmptyState />
</Suspense>
); );
} }

View File

@ -96,8 +96,8 @@ export default function RootLayout({
</ThemeProviderClient> </ThemeProviderClient>
</MoreComponentsProvider> </MoreComponentsProvider>
</AppStateProvider> </AppStateProvider>
<Analytics /> <Analytics debug={false} />
<SpeedInsights /> <SpeedInsights debug={false} />
<PhotoEscapeHandler /> <PhotoEscapeHandler />
<ToasterWithThemes /> <ToasterWithThemes />
</body> </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 { clsx } from 'clsx/lite';
import { ReactNode } from 'react';
import { BiErrorAlt } from 'react-icons/bi'; import { BiErrorAlt } from 'react-icons/bi';
export default function ErrorNote({ export default function ErrorNote({
children, children,
}: { }: {
children: React.ReactNode children: ReactNode
}) { }) {
return ( return (
<div className={clsx( <div className={clsx(

View File

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

View File

@ -1,6 +1,7 @@
import MoreComponents from '@/components/MoreComponents'; import MoreComponents from '@/components/MoreComponents';
import { getPhotosCached } from '@/photo/cache'; import { getPhotosCached } from '@/photo/cache';
import PhotoGrid from './PhotoGrid'; import PhotoGrid from './PhotoGrid';
import { useCallback } from 'react';
export function MorePhotosGrid({ export function MorePhotosGrid({
initialOffset, initialOffset,
@ -11,32 +12,38 @@ export function MorePhotosGrid({
itemsPerRequest: number itemsPerRequest: number
totalPhotosCount: 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 ( return (
<MoreComponents <MoreComponents
stateKey="PhotosGrid" stateKey="PhotosGrid"
label="More photos" label="More photos"
initialOffset={initialOffset} initialOffset={initialOffset}
itemsPerRequest={itemsPerRequest} itemsPerRequest={itemsPerRequest}
getNextComponent={async (offset, limit) => { getNextComponent={getNextComponent}
'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,
};
}
}}
/> />
); );
} }

View File

@ -1,6 +1,7 @@
import MoreComponents from '@/components/MoreComponents'; import MoreComponents from '@/components/MoreComponents';
import PhotosLarge from './PhotosLarge'; import PhotosLarge from './PhotosLarge';
import { getPhotosCached } from '@/photo/cache'; import { getPhotosCached } from '@/photo/cache';
import { useCallback } from 'react';
export function MorePhotosRoot({ export function MorePhotosRoot({
initialOffset, initialOffset,
@ -11,32 +12,36 @@ export function MorePhotosRoot({
itemsPerRequest: number itemsPerRequest: number
totalPhotosCount: 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 ( return (
<MoreComponents <MoreComponents
stateKey="PhotosRoot" stateKey="PhotosRoot"
label="More photos" label="More photos"
initialOffset={initialOffset} initialOffset={initialOffset}
itemsPerRequest={itemsPerRequest} itemsPerRequest={itemsPerRequest}
getNextComponent={async (offset, limit) => { getNextComponent={getNextComponent}
'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,
};
}
}}
wrapMoreButtonInSiteGrid wrapMoreButtonInSiteGrid
/> />
); );

View File

@ -17,7 +17,7 @@ export const GENERATE_STATIC_PARAMS_LIMIT = 1000;
export const INFINITE_SCROLL_MULTIPLE_ROOT = export const INFINITE_SCROLL_MULTIPLE_ROOT =
process.env.NODE_ENV === 'development' ? 2 : 12; process.env.NODE_ENV === 'development' ? 2 : 12;
export const INFINITE_SCROLL_MULTIPLE_GRID = HIGH_DENSITY_GRID 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; : process.env.NODE_ENV === 'development' ? 4 : 24;
export const GRID_THUMBNAILS_TO_SHOW_MAX = 12; export const GRID_THUMBNAILS_TO_SHOW_MAX = 12;

View File

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