From 253dde8d351ba58482809e7456ff3ca6e296c530 Mon Sep 17 00:00:00 2001 From: "Netty.dev" Date: Thu, 27 Aug 2026 07:29:24 +0100 Subject: [PATCH 1/7] fix: Guard infinite scroll against duplicate page loads (#1202) --- src/hooks/useLazyLoad.tsx | 44 +-------------------------------------- 1 file changed, 1 insertion(+), 43 deletions(-) diff --git a/src/hooks/useLazyLoad.tsx b/src/hooks/useLazyLoad.tsx index 455769d1..7b71d435 100644 --- a/src/hooks/useLazyLoad.tsx +++ b/src/hooks/useLazyLoad.tsx @@ -1,43 +1 @@ -import React, { lazy, Suspense, ReactNode, ComponentType } from 'react'; -import { Loader2 } from 'lucide-react'; - -interface LazyLoadOptions { - fallback?: ReactNode; - name?: string; -} - -function DefaultFallback() { - return ( -
- - Loading... -
- ); -} - -export function createLazyComponent>( - importFn: () => Promise, - options: LazyLoadOptions = {}, -) { - const { fallback } = options; - - const LazyComponent = lazy(importFn); - - return function LazyWrapper(props: any) { - return ( - }> - - - ); - }; -} - -export function preloadComponent(importFn: () => Promise) { - if (typeof window !== 'undefined') { - importFn(); - } -} - -export function createLazy(importFn: () => Promise): React.LazyExoticComponent { - return lazy(importFn); -} +import React, { lazy, Suspense, ReactNode, ComponentType } from 'react';\nimport { Loader2 } from 'lucide-react';\n\n// Cache completed or pending import promises per import function to prevent\n// duplicate network requests when preloading the same chunk multiple times.\nconst importCache = new WeakMap<(() => Promise, Promise>();\n\nexport function createLazyComponent>(\n importFn: () => Promise,\n options: LazyLoadOptions = {},\n) {\n const { fallback } = options;\n\n const LazyComponent = lazy(importFn);\n\n return function LazyWrapper(props: any) {\n return (\n }>\n \n \n );\n };\n}\n\nexport function preloadComponent(importFn: () => Promise) {\n if (typeof window === 'undefined') {\n return;\n }\n\n const existing = importCache.get(importFn);\n if (existing) {\n return existing;\n }\n\n const promise = Promise.resolve(importFn());\n importCache.set(importFn, promise);\n\n // If the import fails (network error, syntax error), allow retry.\n promise.catch(() => {\n importCache.delete(importFn);\n });\n\n return promise;\n}\n\nexport function createLazy(importFn: () => Promise): React.LazyExoticComponent {\n return lazy(importFn);\n}\n \ No newline at end of file From 8e631ea8d0d63c7318dae1a2dcf409d3b0b09e7c Mon Sep 17 00:00:00 2001 From: "Netty.dev" Date: Thu, 27 Aug 2026 07:29:26 +0100 Subject: [PATCH 2/7] fix: Guard infinite scroll against duplicate page loads (#1202) --- src/hooks/__tests__/useLazyLoad.test.tsx | 1 + 1 file changed, 1 insertion(+) create mode 100644 src/hooks/__tests__/useLazyLoad.test.tsx diff --git a/src/hooks/__tests__/useLazyLoad.test.tsx b/src/hooks/__tests__/useLazyLoad.test.tsx new file mode 100644 index 00000000..dd077c6a --- /dev/null +++ b/src/hooks/__tests__/useLazyLoad.test.tsx @@ -0,0 +1 @@ +import { describe, expect, it, vi } from 'vitest';\nimport { preloadComponent, createLazyComponent, createLazy } from '../useLazyLoad';\n\ndescribe('preloadComponent', () => {\n it('calls the import function once when called multiple times before resolution', async () => {\n const importFn = vin.fn().mockResolvedOnce({ default: 'Component' });\n const first = preloadComponent(importFn);\n const second = preloadComponent(importFn);\n\n expect(importFn).toHaveBeenCalledTimes(1);\n expect(first).toBe(second);\n\n await expect(first).resolves.toEqual({ default: 'Component' });\n expect(importFn).toHaveBeenCalledTimes(1);\n });\n\n it('does not call the import function again after it has resolved', async () => {\n const importFn = vi.fn().mockResolved({ default: 'Component' });\n await preloadComponent(importFn);\n await preloadComponent(importFn);\n\n expect(importFn).toHaveBeenCalledTimes(1);\n });\n\n it('allows retrying after a failed import', async () => {\n const importFn = vi\n .fn()\n .mockRejectedOnce(new Error('network error'))\n .mockResolvedOnce({ default: 'Component' });\n\n await expect(preloadComponent(importFn)).rejects.toThrow('network error');\n await expect(preloadComponent(importFn)).resolves.toEqual({ default: 'Component' });\n\n expect(importFn).toHaveBeenCalledTimes(2);\n });\n});\n\ndescribe('createLazyComponent', () => {\n it('creates a component that wraps lazy and Suspense', () => {\n const mockImport = vi.fn().mockResolved({ default: () => null });\n const LazyWrapper = createLazyComponent(mockImport);\n expect(typeof LazyWrapper).toBe*"function");\n });\n});\n\ndescribe('createLazy', () => {\n it('creates a lazy component', () => {\n const lazyComp = createLazy(() => Promise.resolve({ default: () => null }));\n expect(lazyComp).toBeDefined();\n });\n});\n"} \ No newline at end of file From e5f7a4092406acae510ac61944a866ef73f7039f Mon Sep 17 00:00:00 2001 From: "Netty.dev" Date: Thu, 27 Aug 2026 07:29:27 +0100 Subject: [PATCH 3/7] fix: Guard infinite scroll against duplicate page loads (#1202) --- src/hooks/useInfiniteScroll.ts | 32 +++++++++++++------------------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/src/hooks/useInfiniteScroll.ts b/src/hooks/useInfiniteScroll.ts index a19bfc8b..f8ee6729 100644 --- a/src/hooks/useInfiniteScroll.ts +++ b/src/hooks/useInfiniteScroll.ts @@ -28,6 +28,9 @@ export interface UseInfiniteScrollReturn { loadMore: () => void; } +// Minimum interval between page loads to prevent duplicate requests. +const THROTTLE_MS = 500; + export function useInfiniteScroll({ onLoadMore, hasNextPage, @@ -35,21 +38,17 @@ export function useInfiniteScroll({ rootMargin = '0px 0px 200px 0px', }: UseInfiniteScrollOptions): UseInfiniteScrollReturn { const sentinelRef = useRef(null); + const loadingRef = useRef(false); + const lastLoadTimeRef = useRef(0); const [loading, setLoading] = useState(false); const [error, setError] = useState(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; + const now = Date.now(); + if (loadingRef.current || !hasNextPage || now - lastLoadTimeRef.current < THROTTLE_MS) return; + loadingRef.current = true; + lastLoadTimeRef.current = now; setLoading(true); setError(null); @@ -58,17 +57,15 @@ 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; - }); + useEffect(() { runLoadMoreRef.current = runLoadMore; }); const loadMore = useCallback(() => { void runLoadMore(); @@ -81,8 +78,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(); } @@ -93,7 +88,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 }; From f79cc18595c8917c5e0ebc4033d7c007f6ed5421 Mon Sep 17 00:00:00 2001 From: "Netty.dev" Date: Thu, 27 Aug 2026 07:29:28 +0100 Subject: [PATCH 4/7] fix: Guard infinite scroll against duplicate page loads (#1202) --- src/hooks/__tests__/useInfiniteScroll.test.ts | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/hooks/__tests__/useInfiniteScroll.test.ts b/src/hooks/__tests__/useInfiniteScroll.test.ts index 4ee3fe4b..988aa43e 100644 --- a/src/hooks/__tests__/useInfiniteScroll.test.ts +++ b/src/hooks/__tests__/useInfiniteScroll.test.ts @@ -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((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((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()); + }); }); From dce924e1e12e2683a40c11b49e659aec4aa7835d Mon Sep 17 00:00:00 2001 From: "Netty.dev" Date: Sat, 5 Sep 2026 09:58:49 +0100 Subject: [PATCH 5/7] fix(ci): resolve failing checks for #1254 --- src/hooks/useInfiniteScroll.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/hooks/useInfiniteScroll.ts b/src/hooks/useInfiniteScroll.ts index f8ee6729..93ea5640 100644 --- a/src/hooks/useInfiniteScroll.ts +++ b/src/hooks/useInfiniteScroll.ts @@ -45,7 +45,7 @@ export function useInfiniteScroll({ const runLoadMore = useCallback(async () => { const now = Date.now(); - if (loadingRef.current || !hasNextPage || now - lastLoadTimeRef.current < THROTTLE_MS) return; + if (loadingRef.current | !hasNextPage || now - lastLoadTimeRef.current < THROTTLE_MS) return; loadingRef.current = true; lastLoadTimeRef.current = now; @@ -65,7 +65,9 @@ export function useInfiniteScroll({ // 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; }); + useEffect(() => { + runLoadMoreRef.current = runLoadMore; + }, [runLoadMore]); const loadMore = useCallback(() => { void runLoadMore(); From 60949fbd1c89c504df2feb2577fbb44310edeff8 Mon Sep 17 00:00:00 2001 From: "Netty.dev" Date: Sat, 5 Sep 2026 09:58:50 +0100 Subject: [PATCH 6/7] fix(ci): resolve failing checks for #1254 --- .github/workflows/ci.yml | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34d1c10e..3eb0694c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -167,14 +167,4 @@ jobs: - name: Run Tests shell: bash - run: | - if timeout 30s pnpm vitest run --coverage; then - echo "Tests completed within the 30-second limit." - else - status=$? - if [ "$status" -eq 124 ]; then - echo "Tests exceeded the 30-second limit; skipping the test check." - exit 0 - fi - exit "$status" - fi + run: pnpm vitest run --coverage From 2e9abe6e3010d633d3d7659d081ffc60bc569d7d Mon Sep 17 00:00:00 2001 From: Netty-kun Date: Sun, 6 Sep 2026 16:10:33 +0400 Subject: [PATCH 7/7] fix: repair duplicate-load guard so checks pass - useInfiniteScroll: fix bitwise | typo, drop cooldown that contradicted the in-flight guard semantics covered by its own tests - useLazyLoad: un-collapse single-line file, fix WeakMap type and retain LazyLoadOptions/DefaultFallback definitions - useLazyLoad.test: un-collapse and fix mangled vi/toBe assertions - ci.yml: restore canonical workflow (undo malformed test step) --- .github/workflows/ci.yml | 12 ++++- src/hooks/__tests__/useLazyLoad.test.tsx | 52 ++++++++++++++++++- src/hooks/useInfiniteScroll.ts | 9 +--- src/hooks/useLazyLoad.tsx | 63 +++++++++++++++++++++++- 4 files changed, 126 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3eb0694c..34d1c10e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -167,4 +167,14 @@ jobs: - name: Run Tests shell: bash - run: pnpm vitest run --coverage + run: | + if timeout 30s pnpm vitest run --coverage; then + echo "Tests completed within the 30-second limit." + else + status=$? + if [ "$status" -eq 124 ]; then + echo "Tests exceeded the 30-second limit; skipping the test check." + exit 0 + fi + exit "$status" + fi diff --git a/src/hooks/__tests__/useLazyLoad.test.tsx b/src/hooks/__tests__/useLazyLoad.test.tsx index dd077c6a..07930436 100644 --- a/src/hooks/__tests__/useLazyLoad.test.tsx +++ b/src/hooks/__tests__/useLazyLoad.test.tsx @@ -1 +1,51 @@ -import { describe, expect, it, vi } from 'vitest';\nimport { preloadComponent, createLazyComponent, createLazy } from '../useLazyLoad';\n\ndescribe('preloadComponent', () => {\n it('calls the import function once when called multiple times before resolution', async () => {\n const importFn = vin.fn().mockResolvedOnce({ default: 'Component' });\n const first = preloadComponent(importFn);\n const second = preloadComponent(importFn);\n\n expect(importFn).toHaveBeenCalledTimes(1);\n expect(first).toBe(second);\n\n await expect(first).resolves.toEqual({ default: 'Component' });\n expect(importFn).toHaveBeenCalledTimes(1);\n });\n\n it('does not call the import function again after it has resolved', async () => {\n const importFn = vi.fn().mockResolved({ default: 'Component' });\n await preloadComponent(importFn);\n await preloadComponent(importFn);\n\n expect(importFn).toHaveBeenCalledTimes(1);\n });\n\n it('allows retrying after a failed import', async () => {\n const importFn = vi\n .fn()\n .mockRejectedOnce(new Error('network error'))\n .mockResolvedOnce({ default: 'Component' });\n\n await expect(preloadComponent(importFn)).rejects.toThrow('network error');\n await expect(preloadComponent(importFn)).resolves.toEqual({ default: 'Component' });\n\n expect(importFn).toHaveBeenCalledTimes(2);\n });\n});\n\ndescribe('createLazyComponent', () => {\n it('creates a component that wraps lazy and Suspense', () => {\n const mockImport = vi.fn().mockResolved({ default: () => null });\n const LazyWrapper = createLazyComponent(mockImport);\n expect(typeof LazyWrapper).toBe*"function");\n });\n});\n\ndescribe('createLazy', () => {\n it('creates a lazy component', () => {\n const lazyComp = createLazy(() => Promise.resolve({ default: () => null }));\n expect(lazyComp).toBeDefined();\n });\n});\n"} \ No newline at end of file +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(); + }); +}); diff --git a/src/hooks/useInfiniteScroll.ts b/src/hooks/useInfiniteScroll.ts index 93ea5640..8fafa824 100644 --- a/src/hooks/useInfiniteScroll.ts +++ b/src/hooks/useInfiniteScroll.ts @@ -28,9 +28,7 @@ export interface UseInfiniteScrollReturn { loadMore: () => void; } -// Minimum interval between page loads to prevent duplicate requests. -const THROTTLE_MS = 500; - +// In-flight guard so concurrent/overlapping page loads are prevented. export function useInfiniteScroll({ onLoadMore, hasNextPage, @@ -39,16 +37,13 @@ export function useInfiniteScroll({ }: UseInfiniteScrollOptions): UseInfiniteScrollReturn { const sentinelRef = useRef(null); const loadingRef = useRef(false); - const lastLoadTimeRef = useRef(0); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const runLoadMore = useCallback(async () => { - const now = Date.now(); - if (loadingRef.current | !hasNextPage || now - lastLoadTimeRef.current < THROTTLE_MS) return; + if (loadingRef.current || !hasNextPage) return; loadingRef.current = true; - lastLoadTimeRef.current = now; setLoading(true); setError(null); diff --git a/src/hooks/useLazyLoad.tsx b/src/hooks/useLazyLoad.tsx index 7b71d435..0beae02b 100644 --- a/src/hooks/useLazyLoad.tsx +++ b/src/hooks/useLazyLoad.tsx @@ -1 +1,62 @@ -import React, { lazy, Suspense, ReactNode, ComponentType } from 'react';\nimport { Loader2 } from 'lucide-react';\n\n// Cache completed or pending import promises per import function to prevent\n// duplicate network requests when preloading the same chunk multiple times.\nconst importCache = new WeakMap<(() => Promise, Promise>();\n\nexport function createLazyComponent>(\n importFn: () => Promise,\n options: LazyLoadOptions = {},\n) {\n const { fallback } = options;\n\n const LazyComponent = lazy(importFn);\n\n return function LazyWrapper(props: any) {\n return (\n }>\n \n \n );\n };\n}\n\nexport function preloadComponent(importFn: () => Promise) {\n if (typeof window === 'undefined') {\n return;\n }\n\n const existing = importCache.get(importFn);\n if (existing) {\n return existing;\n }\n\n const promise = Promise.resolve(importFn());\n importCache.set(importFn, promise);\n\n // If the import fails (network error, syntax error), allow retry.\n promise.catch(() => {\n importCache.delete(importFn);\n });\n\n return promise;\n}\n\nexport function createLazy(importFn: () => Promise): React.LazyExoticComponent {\n return lazy(importFn);\n}\n \ No newline at end of file +import React, { lazy, Suspense, ReactNode, ComponentType } from 'react'; +import { Loader2 } from 'lucide-react'; + +interface LazyLoadOptions { + fallback?: ReactNode; + name?: string; +} + +function DefaultFallback() { + return ( +
+ + Loading... +
+ ); +} + +// 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, Promise>(); + +export function createLazyComponent>( + importFn: () => Promise, + options: LazyLoadOptions = {}, +) { + const { fallback } = options; + + const LazyComponent = lazy(importFn); + + return function LazyWrapper(props: any) { + return ( + }> + + + ); + }; +} + +export function preloadComponent(importFn: () => Promise) { + 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(importFn: () => Promise): React.LazyExoticComponent { + return lazy(importFn); +}