Skip to content
49 changes: 49 additions & 0 deletions src/hooks/__tests__/useInfiniteScroll.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,53 @@ describe('useInfiniteScroll', () => {

expect(onLoadMore).not.toHaveBeenCalled();
});

it('does not call onLoadMore again while a load is pending', async () => {
makeObserver(true);
let resolve!: () => void;
const onLoadMore = vi.fn(
() =>
new Promise<void>((res) => {
resolve = res;
}),
);

const { result } = renderHook(() => useInfiniteScroll({ onLoadMore, hasNextPage: true }));

act(() => {
result.current.loadMore();
});
act(() => {
result.current.loadMore();
});

expect(onLoadMore).toHaveBeenCalledTimes(1);
await act(async () => resolve());
expect(result.current.loading).toBe(false);
});

it('calls onLoadMore again after the previous load resolves', async () => {
makeObserver(true);
let resolve!: () => void;
const onLoadMore = vi.fn(
() =>
new Promise<void>((res) => {
resolve = res;
}),
);

const { result } = renderHook(() => useInfiniteScroll({ onLoadMore, hasNextPage: true }));

act(() => {
result.current.loadMore();
});
await act(async () => resolve());

act(() => {
result.current.loadMore();
});

expect(onLoadMore).toHaveBeenCalledTimes(2);
await act(async () => resolve());
});
});
51 changes: 51 additions & 0 deletions src/hooks/__tests__/useLazyLoad.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, it, vi } from 'vitest';
import { preloadComponent, createLazyComponent, createLazy } from '../useLazyLoad';

describe('preloadComponent', () => {
it('calls the import function once when called multiple times before resolution', async () => {
const importFn = vi.fn().mockResolvedValueOnce({ default: 'Component' });
const first = preloadComponent(importFn);
const second = preloadComponent(importFn);

expect(importFn).toHaveBeenCalledTimes(1);
expect(first).toBe(second);

await expect(first).resolves.toEqual({ default: 'Component' });
expect(importFn).toHaveBeenCalledTimes(1);
});

it('does not call the import function again after it has resolved', async () => {
const importFn = vi.fn().mockResolvedValue({ default: 'Component' });
await preloadComponent(importFn);
await preloadComponent(importFn);

expect(importFn).toHaveBeenCalledTimes(1);
});

it('allows retrying after a failed import', async () => {
const importFn = vi
.fn()
.mockRejectedValueOnce(new Error('network error'))
.mockResolvedValueOnce({ default: 'Component' });

await expect(preloadComponent(importFn)).rejects.toThrow('network error');
await expect(preloadComponent(importFn)).resolves.toEqual({ default: 'Component' });

expect(importFn).toHaveBeenCalledTimes(2);
});
});

describe('createLazyComponent', () => {
it('creates a component that wraps lazy and Suspense', () => {
const mockImport = vi.fn().mockResolvedValue({ default: () => null });
const LazyWrapper = createLazyComponent(mockImport);
expect(typeof LazyWrapper).toBe('function');
});
});

describe('createLazy', () => {
it('creates a lazy component', () => {
const lazyComp = createLazy(() => Promise.resolve({ default: () => null }));
expect(lazyComp).toBeDefined();
});
});
23 changes: 7 additions & 16 deletions src/hooks/useInfiniteScroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,28 +28,22 @@ export interface UseInfiniteScrollReturn {
loadMore: () => void;
}

// In-flight guard so concurrent/overlapping page loads are prevented.
export function useInfiniteScroll({
onLoadMore,
hasNextPage,
threshold = 0,
rootMargin = '0px 0px 200px 0px',
}: UseInfiniteScrollOptions): UseInfiniteScrollReturn {
const sentinelRef = useRef<HTMLDivElement | null>(null);
const loadingRef = useRef(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<unknown>(null);

// Keep a ref in sync with the loading state so the observer callback can
// read the latest value without being listed as an effect dependency.
// This prevents the IntersectionObserver from being torn down and recreated
// on every loading transition.
const loadingRef = useRef(loading);
useEffect(() => {
loadingRef.current = loading;
}, [loading]);

const runLoadMore = useCallback(async () => {
if (loadingRef.current || !hasNextPage) return;

loadingRef.current = true;
setLoading(true);
setError(null);

Expand All @@ -58,17 +52,17 @@ export function useInfiniteScroll({
} catch (err) {
setError(err);
} finally {
loadingRef.current = false;
setLoading(false);
}
}, [hasNextPage, onLoadMore]);

// Keep a stable ref to runLoadMore so the observer effect does not need to
// list it as a dependency. The ref is updated on every render, meaning the
// callback inside the observer always calls the latest version.
// Keep a ref in sync with the latest runLoadMore so the observer callback can
// always call the current version without being a dependency.
const runLoadMoreRef = useRef(runLoadMore);
useEffect(() => {
runLoadMoreRef.current = runLoadMore;
});
}, [runLoadMore]);

const loadMore = useCallback(() => {
void runLoadMore();
Expand All @@ -81,8 +75,6 @@ export function useInfiniteScroll({
const observer = new IntersectionObserver(
(entries) => {
const first = entries[0];
// Read loading from the ref — no need to list it as a dep, so the
// observer is never recreated just because loading flipped.
if (first?.isIntersecting && !loadingRef.current) {
void runLoadMoreRef.current();
}
Expand All @@ -93,7 +85,6 @@ export function useInfiniteScroll({
observer.observe(sentinel);

return () => observer.disconnect();
// loading and runLoadMore intentionally omitted — accessed via refs above.
}, [hasNextPage, rootMargin, threshold]);

return { sentinelRef, loading, error, loadMore };
Expand Down
23 changes: 21 additions & 2 deletions src/hooks/useLazyLoad.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ function DefaultFallback() {
);
}

// Cache completed or pending import promises per import function to prevent
// duplicate network requests when preloading the same chunk multiple times.
const importCache = new WeakMap<() => Promise<unknown>, Promise<unknown>>();

export function createLazyComponent<T extends ComponentType<any>>(
importFn: () => Promise<any>,
options: LazyLoadOptions = {},
Expand All @@ -33,9 +37,24 @@ export function createLazyComponent<T extends ComponentType<any>>(
}

export function preloadComponent(importFn: () => Promise<unknown>) {
if (typeof window !== 'undefined') {
importFn();
if (typeof window === 'undefined') {
return;
}

const existing = importCache.get(importFn);
if (existing) {
return existing;
}

const promise = Promise.resolve(importFn());
importCache.set(importFn, promise);

// If the import fails (network error, syntax error), allow retry.
promise.catch(() => {
importCache.delete(importFn);
});

return promise;
}

export function createLazy<T>(importFn: () => Promise<any>): React.LazyExoticComponent<T> {
Expand Down
Loading