refactor(web): unify infinite scroll sentinels (#40188)

This commit is contained in:
yyh authored and GitHub committed 2026-08-10 02:22:07 +00:00
1 parent 883944d289
commit c2687c16b3
17 files changed
+554 -885

No files matched your search

@@ -1,104 +0,0 @@
import type { RefObject } from 'react'
import { act, render } from '@testing-library/react'
import { useRef } from 'react'
import { AppListInfiniteScrollSentinel } from '../app-list-infinite-scroll-sentinel'
type MockObserver = {
callback: IntersectionObserverCallback
options?: IntersectionObserverInit
}
const observers: MockObserver[] = []
function getObserver(index: number) {
const observer = observers[index]
if (!observer) throw new Error(`Missing observer at index ${index}`)
return observer
}
function Harness({
canLoadMore,
fetchNextPage,
}: {
canLoadMore: boolean
fetchNextPage: () => Promise<unknown>
}) {
const scrollViewportRef = useRef<HTMLDivElement>(null)
return (
<div ref={scrollViewportRef}>
<AppListInfiniteScrollSentinel
canLoadMore={canLoadMore}
fetchNextPage={fetchNextPage}
scrollViewportRef={scrollViewportRef as RefObject<HTMLDivElement | null>}
/>
</div>
)
}
describe('AppListInfiniteScrollSentinel', () => {
beforeEach(() => {
observers.length = 0
vi.stubGlobal(
'IntersectionObserver',
class MockIntersectionObserver {
callback: IntersectionObserverCallback
disconnect() {}
observe() {}
root = null
rootMargin = ''
thresholds = []
takeRecords = () => []
unobserve = vi.fn()
constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
this.callback = callback
observers.push({
callback,
options,
})
}
},
)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('loads again after the busy state clears', () => {
const fetchNextPage = vi.fn().mockResolvedValue(undefined)
const { container, rerender } = render(<Harness canLoadMore fetchNextPage={fetchNextPage} />)
const firstObserver = getObserver(0)
const scrollRoot = container.firstElementChild
expect(firstObserver.options?.root).toBe(scrollRoot)
act(() => {
firstObserver.callback(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
})
expect(fetchNextPage).toHaveBeenCalledTimes(1)
rerender(<Harness canLoadMore={false} fetchNextPage={fetchNextPage} />)
act(() => {
firstObserver.callback(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
})
expect(fetchNextPage).toHaveBeenCalledTimes(1)
rerender(<Harness canLoadMore fetchNextPage={fetchNextPage} />)
const secondObserver = getObserver(1)
act(() => {
secondObserver.callback(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
})
expect(fetchNextPage).toHaveBeenCalledTimes(2)
})
})
+17 -9
View File
@@ -9,17 +9,18 @@ import type { GetSystemFeaturesResponse } from '@dify/contracts/api/console/syst
import type { RefObject } from 'react'
import type { App } from '@/models/explore'
import type { TryAppSelection } from '@/types/try-app'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { keepPreviousData, useInfiniteQuery, useQuery } from '@tanstack/react-query'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { InfiniteScrollSentinel } from '@/app/components/base/infinite-scroll-sentinel'
import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { consoleQuery } from '@/service/client'
import { AppModeEnum } from '@/types/app'
import { AppCard } from './app-card'
import { AppCardSkeleton } from './app-card-skeleton'
import { AppListInfiniteScrollSentinel } from './app-list-infinite-scroll-sentinel'
import { APP_LIST_GRID_CLASS_NAME } from './constants'
import Empty from './empty'
import FirstEmptyState from './first-empty-state'
@@ -31,6 +32,9 @@ const STARRED_APP_LIMIT = 100
const STEP_BY_STEP_TOUR_APP_ROW_CARD_COUNT = 4
const emptyStarredApps: AppPartial[] = []
const getPreloadDistance = (scrollContainer: Element) =>
Math.max(160, Math.min(scrollContainer.clientHeight * 0.25, 320))
type AppListQuery = NonNullable<GetAppsData['query']>
type AppListCatalogProps = Readonly<{
@@ -205,26 +209,30 @@ function AppListCatalogContent({
)}
{hasNextPage && (
<div className="relative col-span-full">
<AppListInfiniteScrollSentinel
<InfiniteScrollSentinel
canLoadMore={!isFetching && !isFetchNextPageError}
fetchNextPage={onFetchNextPage}
scrollViewportRef={scrollViewportRef}
onLoadMore={() => {
void onFetchNextPage()
}}
preloadDistance={getPreloadDistance}
scrollContainerRef={scrollViewportRef}
/>
<div className="relative grid grid-cols-[repeat(auto-fill,minmax(296px,1fr))] gap-2.5">
<AppCardSkeleton count={3} />
{isFetchNextPageError && !isFetchingNextPage && (
{isFetchNextPageError && (
<div
className="absolute inset-0 flex items-center justify-center gap-2 bg-background-body system-xs-regular text-text-tertiary"
role="alert"
>
<span>{t(($) => $['errorBoundary.title'], { ns: 'common' })}</span>
<button
type="button"
className="text-text-accent outline-hidden hover:underline focus-visible:underline"
<Button
loading={isFetchingNextPage}
size="small"
variant="secondary"
onClick={() => void onFetchNextPage()}
>
{t(($) => $['operation.retry'], { ns: 'common' })}
</button>
</Button>
</div>
)}
</div>
@@ -1,45 +0,0 @@
'use client'
import type { RefObject } from 'react'
import { useEffect, useEffectEvent, useRef } from 'react'
type AppListInfiniteScrollSentinelProps = {
canLoadMore: boolean
fetchNextPage: () => Promise<unknown>
scrollViewportRef: RefObject<HTMLDivElement | null>
}
export function AppListInfiniteScrollSentinel({
canLoadMore,
fetchNextPage,
scrollViewportRef,
}: AppListInfiniteScrollSentinelProps) {
const sentinelRef = useRef<HTMLDivElement>(null)
const handleIntersection = useEffectEvent((entry: IntersectionObserverEntry) => {
if (entry.isIntersecting && canLoadMore) void fetchNextPage()
})
useEffect(() => {
const scrollRoot = scrollViewportRef.current
const sentinel = sentinelRef.current
if (!canLoadMore || !scrollRoot || !sentinel || typeof IntersectionObserver === 'undefined')
return
const preloadDistance = Math.max(160, Math.min(scrollRoot.clientHeight * 0.25, 320))
const observer = new IntersectionObserver(
([entry]) => {
if (entry) handleIntersection(entry)
},
{
root: scrollRoot,
rootMargin: `0px 0px ${preloadDistance}px 0px`,
threshold: 0,
},
)
observer.observe(sentinel)
return () => observer.disconnect()
}, [canLoadMore, scrollViewportRef])
return <div ref={sentinelRef} aria-hidden className="h-px" />
}
@@ -0,0 +1,164 @@
import { act, render } from '@testing-library/react'
import { useRef } from 'react'
import { InfiniteScrollSentinel } from '..'
type ObserverRecord = {
callback: IntersectionObserverCallback
disconnect: ReturnType<typeof vi.fn>
observe: ReturnType<typeof vi.fn>
options?: IntersectionObserverInit
}
const observers: ObserverRecord[] = []
function getObserver(index: number) {
const observer = observers[index]
if (!observer) throw new Error(`Missing observer at index ${index}`)
return observer
}
function triggerIntersection(observer: ObserverRecord, isIntersecting: boolean) {
act(() => {
observer.callback([{ isIntersecting } as IntersectionObserverEntry], {} as IntersectionObserver)
})
}
function Harness({
canLoadMore,
onLoadMore,
preloadDistance,
}: {
canLoadMore: boolean
onLoadMore: () => void
preloadDistance: number | ((scrollContainer: Element) => number)
}) {
const scrollContainerRef = useRef<HTMLDivElement>(null)
return (
<div ref={scrollContainerRef}>
<InfiniteScrollSentinel
scrollContainerRef={scrollContainerRef}
canLoadMore={canLoadMore}
preloadDistance={preloadDistance}
onLoadMore={onLoadMore}
/>
</div>
)
}
describe('InfiniteScrollSentinel', () => {
beforeEach(() => {
observers.length = 0
vi.stubGlobal(
'IntersectionObserver',
class MockIntersectionObserver {
readonly root: Element | Document | null
readonly rootMargin: string
readonly scrollMargin = ''
readonly thresholds: ReadonlyArray<number>
readonly disconnect = vi.fn()
readonly observe = vi.fn()
readonly takeRecords = () => []
readonly unobserve = vi.fn()
constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
this.root = options?.root ?? null
this.rootMargin = options?.rootMargin ?? ''
this.thresholds = Array.isArray(options?.threshold)
? options.threshold
: [options?.threshold ?? 0]
observers.push({
callback,
disconnect: this.disconnect,
observe: this.observe,
options,
})
}
},
)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('observes the sentinel relative to the provided scroll container', () => {
const { container } = render(<Harness canLoadMore preloadDistance={160} onLoadMore={vi.fn()} />)
const observer = getObserver(0)
const scrollContainer = container.firstElementChild
expect(observer.options).toEqual({
root: scrollContainer,
rootMargin: '0px 0px 160px 0px',
threshold: 0,
})
expect(observer.observe).toHaveBeenCalledOnce()
})
it('resolves a business-owned preload distance from the scroll container', () => {
const getPreloadDistance = vi.fn(() => 240)
const { container } = render(
<Harness canLoadMore preloadDistance={getPreloadDistance} onLoadMore={vi.fn()} />,
)
const scrollContainer = container.firstElementChild
expect(getPreloadDistance).toHaveBeenCalledWith(scrollContainer)
expect(getObserver(0).options?.rootMargin).toBe('0px 0px 240px 0px')
})
it('notifies whenever the sentinel enters the preload area while loading is allowed', () => {
const onLoadMore = vi.fn()
render(<Harness canLoadMore preloadDistance={160} onLoadMore={onLoadMore} />)
const observer = getObserver(0)
triggerIntersection(observer, true)
triggerIntersection(observer, false)
triggerIntersection(observer, true)
expect(onLoadMore).toHaveBeenCalledTimes(2)
})
it('pauses load notifications while loading is disallowed and resumes when allowed again', () => {
const onLoadMore = vi.fn()
const { rerender } = render(
<Harness canLoadMore preloadDistance={160} onLoadMore={onLoadMore} />,
)
const firstObserver = getObserver(0)
triggerIntersection(firstObserver, true)
expect(onLoadMore).toHaveBeenCalledOnce()
rerender(<Harness canLoadMore={false} preloadDistance={160} onLoadMore={onLoadMore} />)
triggerIntersection(firstObserver, true)
expect(onLoadMore).toHaveBeenCalledOnce()
rerender(<Harness canLoadMore preloadDistance={160} onLoadMore={onLoadMore} />)
triggerIntersection(getObserver(1), true)
expect(onLoadMore).toHaveBeenCalledTimes(2)
})
it('uses the latest load callback after rerendering', () => {
const firstOnLoadMore = vi.fn()
const nextOnLoadMore = vi.fn()
const { rerender } = render(
<Harness canLoadMore preloadDistance={160} onLoadMore={firstOnLoadMore} />,
)
rerender(<Harness canLoadMore preloadDistance={160} onLoadMore={nextOnLoadMore} />)
triggerIntersection(getObserver(observers.length - 1), true)
expect(firstOnLoadMore).not.toHaveBeenCalled()
expect(nextOnLoadMore).toHaveBeenCalledOnce()
})
it('disconnects the observer on unmount', () => {
const { unmount } = render(<Harness canLoadMore preloadDistance={160} onLoadMore={vi.fn()} />)
const observer = getObserver(0)
unmount()
expect(observer.disconnect).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,56 @@
'use client'
import type { RefObject } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { useEffect, useEffectEvent, useRef } from 'react'
type PreloadDistance = number | ((scrollContainer: Element) => number)
type InfiniteScrollSentinelProps = Readonly<{
canLoadMore: boolean
className?: string
onLoadMore: () => void
preloadDistance: PreloadDistance
scrollContainerRef: RefObject<Element | null>
}>
export function InfiniteScrollSentinel({
canLoadMore,
className,
onLoadMore,
preloadDistance,
scrollContainerRef,
}: InfiniteScrollSentinelProps) {
const sentinelRef = useRef<HTMLDivElement>(null)
const handleIntersection = useEffectEvent((entry: IntersectionObserverEntry) => {
if (entry.isIntersecting && canLoadMore) onLoadMore()
})
useEffect(() => {
if (!canLoadMore) return
const scrollContainer = scrollContainerRef.current
const sentinel = sentinelRef.current
if (!scrollContainer || !sentinel || typeof IntersectionObserver === 'undefined') return
const resolvedPreloadDistance =
typeof preloadDistance === 'function' ? preloadDistance(scrollContainer) : preloadDistance
const observer = new IntersectionObserver(
([entry]) => {
if (entry) handleIntersection(entry)
},
{
root: scrollContainer,
rootMargin: `0px 0px ${resolvedPreloadDistance}px 0px`,
threshold: 0,
},
)
observer.observe(sentinel)
return () => observer.disconnect()
}, [canLoadMore, preloadDistance, scrollContainerRef])
return <div ref={sentinelRef} aria-hidden className={cn('h-px', className)} />
}
@@ -1,95 +0,0 @@
import { act, render } from '@testing-library/react'
import { useRef } from 'react'
import { InfiniteScrollSentinel } from '../infinite-scroll-sentinel'
type MockObserver = {
callback: IntersectionObserverCallback
}
const observers: MockObserver[] = []
function Harness({
canLoadMore,
fetchNextPage,
}: {
canLoadMore: boolean
fetchNextPage: () => Promise<unknown>
}) {
const scrollRootRef = useRef<HTMLDivElement>(null)
return (
<div ref={scrollRootRef}>
<InfiniteScrollSentinel
canLoadMore={canLoadMore}
fetchNextPage={fetchNextPage}
scrollRootRef={scrollRootRef}
/>
</div>
)
}
describe('InfiniteScrollSentinel', () => {
beforeEach(() => {
observers.length = 0
vi.stubGlobal(
'IntersectionObserver',
class MockIntersectionObserver {
callback: IntersectionObserverCallback
disconnect = vi.fn()
observe = vi.fn()
root = null
rootMargin = ''
thresholds = []
takeRecords = () => []
unobserve = vi.fn()
constructor(callback: IntersectionObserverCallback) {
this.callback = callback
observers.push({ callback })
}
},
)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('does not fetch again while busy and resumes when the query can load again', () => {
const fetchNextPage = vi.fn().mockResolvedValue(undefined)
const { rerender } = render(<Harness canLoadMore fetchNextPage={fetchNextPage} />)
act(() => {
observers
.at(-1)
?.callback(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
})
expect(fetchNextPage).toHaveBeenCalledOnce()
rerender(<Harness canLoadMore={false} fetchNextPage={fetchNextPage} />)
act(() => {
observers
.at(-1)
?.callback(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
})
expect(fetchNextPage).toHaveBeenCalledOnce()
rerender(<Harness canLoadMore fetchNextPage={fetchNextPage} />)
act(() => {
observers
.at(-1)
?.callback(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
})
expect(fetchNextPage).toHaveBeenCalledTimes(2)
})
})
@@ -1,45 +0,0 @@
'use client'
import type { RefObject } from 'react'
import { useEffect, useEffectEvent, useRef } from 'react'
type InfiniteScrollSentinelProps = {
canLoadMore: boolean
fetchNextPage: () => Promise<unknown>
scrollRootRef: RefObject<HTMLDivElement | null>
}
export const InfiniteScrollSentinel = ({
canLoadMore,
fetchNextPage,
scrollRootRef,
}: InfiniteScrollSentinelProps) => {
const sentinelRef = useRef<HTMLDivElement>(null)
const handleIntersection = useEffectEvent((entry: IntersectionObserverEntry) => {
if (entry.isIntersecting && canLoadMore) void fetchNextPage()
})
useEffect(() => {
const scrollRoot = scrollRootRef.current
const sentinel = sentinelRef.current
if (!canLoadMore || !scrollRoot || !sentinel || typeof IntersectionObserver === 'undefined')
return
const preloadDistance = Math.max(160, Math.min(scrollRoot.clientHeight * 0.25, 320))
const observer = new IntersectionObserver(
([entry]) => {
if (entry) handleIntersection(entry)
},
{
root: scrollRoot,
rootMargin: `0px 0px ${preloadDistance}px 0px`,
threshold: 0,
},
)
observer.observe(sentinel)
return () => observer.disconnect()
}, [canLoadMore, scrollRootRef])
return <div ref={sentinelRef} aria-hidden className="h-px" />
}
@@ -377,6 +377,14 @@ vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.dify.ai${path}`,
}))
vi.mock('@/next/dynamic', async () => {
const { default: WebAppsSection } = await import('../components/web-apps-section')
return {
default: () => WebAppsSection,
}
})
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
return {
@@ -399,6 +407,40 @@ let mockInstalledAppsPending = false
let mockInstalledAppsHasNextPage = false
let mockWorkspaces: TenantListItemResponse[] = []
function stubScrollRootIntersectionObserver() {
const observers: Array<{
callback: IntersectionObserverCallback
root: Element | Document | null | undefined
}> = []
vi.stubGlobal(
'IntersectionObserver',
class MockIntersectionObserver {
constructor(nextCallback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
observers.push({ callback: nextCallback, root: options?.root })
}
observe() {}
unobserve() {}
disconnect() {}
},
)
return async () => {
await waitFor(() => {
expect(observers.some(({ root }) => root instanceof Element)).toBe(true)
})
const observer = observers.find(({ root }) => root instanceof Element)
if (!observer) throw new Error('The scroll root observer was not created')
act(() => {
observer.callback(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
})
}
}
const ownerWorkspacePermissionKeys = [
'workspace.member.manage',
'workspace.role.manage',
@@ -1410,36 +1452,92 @@ describe('MainNav', () => {
})
it('fetches the next installed web app page when the bottom sentinel enters the viewport', async () => {
let intersectionCallback: IntersectionObserverCallback | undefined
vi.stubGlobal(
'IntersectionObserver',
class MockIntersectionObserver {
constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
if (options?.root) intersectionCallback = callback
}
observe() {}
unobserve() {}
disconnect() {}
},
)
const triggerIntersection = stubScrollRootIntersectionObserver()
mockInstalledApps = [createInstalledApp()]
mockInstalledAppsHasNextPage = true
renderMainNav()
await screen.findByText('Alpha App')
act(() => {
intersectionCallback?.(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
})
await triggerIntersection()
await waitFor(() => {
expect(mockFetchNextInstalledAppsPage).toHaveBeenCalledWith('next-page')
})
})
it('shows next-page errors at the pagination boundary and retries from there', async () => {
const user = userEvent.setup()
let nextPageAttempts = 0
let resolveNextPage: (() => void) | undefined
const nextPagePending = new Promise<void>((resolve) => {
resolveNextPage = resolve
})
const triggerIntersection = stubScrollRootIntersectionObserver()
mockInstalledApps = [createInstalledApp()]
mockInstalledAppsRequest.mockImplementation(
async ({ query }: { query: { cursor?: string; name?: string } }) => {
if (!query.cursor) {
return {
installed_apps: mockInstalledApps,
has_more: true,
next_cursor: 'next-page',
}
}
nextPageAttempts += 1
if (nextPageAttempts === 1) throw new Error('Failed to load the next page')
await nextPagePending
return {
installed_apps: [
createInstalledApp({
id: 'installed-2',
app: { ...createInstalledApp().app, name: 'Beta Tool' },
}),
],
has_more: false,
next_cursor: null,
}
},
)
renderMainNav()
const firstAppLink = await screen.findByRole('link', {
name: 'common.mainNav.webApps.openApp:{"name":"Alpha App"}',
})
await triggerIntersection()
const paginationError = await screen.findByRole('alert')
expect(
firstAppLink.compareDocumentPosition(paginationError) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
const retryButton = screen.getByRole('button', { name: 'common.operation.retry' })
const webAppsRegion = screen.getByRole('region', { name: 'explore.sidebar.webApps' })
retryButton.focus()
expect(retryButton).toHaveFocus()
await user.keyboard('{Enter}')
await waitFor(() => {
expect(webAppsRegion).toHaveAttribute('aria-busy', 'true')
expect(retryButton).toBeInTheDocument()
expect(retryButton).toHaveFocus()
expect(retryButton).toHaveAttribute('aria-disabled', 'true')
})
await user.keyboard('{Enter}')
expect(nextPageAttempts).toBe(2)
act(() => {
resolveNextPage?.()
})
expect(await screen.findByText('Beta Tool')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('collapses and expands installed web apps from the section arrow', async () => {
const user = userEvent.setup()
mockInstalledApps = [createInstalledApp()]
@@ -14,6 +14,7 @@ import {
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
ScrollArea,
@@ -28,9 +29,9 @@ import { useAtomValue } from 'jotai'
import { Fragment, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import { InfiniteScrollSentinel } from '@/app/components/base/infinite-scroll-sentinel'
import { SearchInput } from '@/app/components/base/search-input'
import AppNavItem from '@/app/components/explore/installed-app-navigation/app-nav-item'
import { InfiniteScrollSentinel } from '@/app/components/explore/installed-app-navigation/infinite-scroll-sentinel'
import { InstalledAppPaginationSkeleton } from '@/app/components/explore/installed-app-navigation/pagination-skeleton'
import { isInstalledAppPath } from '@/app/components/explore/installed-app/routes'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
@@ -40,6 +41,9 @@ import { hasPermission } from '@/utils/permission'
const emptyInstalledApps: InstalledAppResponse[] = []
const getPreloadDistance = (scrollContainer: Element) =>
Math.max(160, Math.min(scrollContainer.clientHeight * 0.25, 320))
const selectInstalledApps = (data: InfiniteData<InstalledAppListResponse, string | undefined>) =>
data.pages.flatMap((page) => page.installed_apps)
@@ -78,9 +82,7 @@ const WebAppsSectionContent = () => {
)
const pinnedAppsCount = installedApps.filter(({ is_pinned }) => is_pinned).length
const canLoadMore = Boolean(
installedAppsQuery.hasNextPage && !installedAppsQuery.isFetching && !installedAppsQuery.error,
)
const canLoadMore = !installedAppsQuery.isFetching && !installedAppsQuery.error
const handleSearchTextChange = (value: string) => {
scrollRef.current?.scrollTo({ top: 0 })
@@ -193,23 +195,21 @@ const WebAppsSectionContent = () => {
role="region"
>
<ScrollAreaContent style={{ minWidth: 0 }} className="w-full max-w-full px-2">
{installedAppsQuery.isError && (
{installedAppsQuery.isError && !installedAppsQuery.isFetchNextPageError && (
<div
className="flex flex-col items-start gap-1 px-2 py-2 system-xs-regular text-text-tertiary"
role="alert"
>
<span>{t(($) => $['errorBoundary.title'], { ns: 'common' })}</span>
<button
type="button"
className="text-text-accent outline-hidden hover:underline focus-visible:underline"
<Button
size="small"
variant="secondary"
onClick={() => {
if (installedAppsQuery.isFetchNextPageError)
void installedAppsQuery.fetchNextPage({ cancelRefetch: false })
else void installedAppsQuery.refetch()
void installedAppsQuery.refetch()
}}
>
{t(($) => $['operation.retry'], { ns: 'common' })}
</button>
</Button>
</div>
)}
{!installedAppsQuery.isError && installedApps.length === 0 && (
@@ -229,16 +229,39 @@ const WebAppsSectionContent = () => {
))}
</div>
)}
{installedAppsQuery.isFetchingNextPage && <InstalledAppPaginationSkeleton />}
<InfiniteScrollSentinel
canLoadMore={canLoadMore}
fetchNextPage={() =>
installedAppsQuery.fetchNextPage({
cancelRefetch: false,
})
}
scrollRootRef={scrollRef}
/>
{installedAppsQuery.hasNextPage && (
<div className="relative">
<InfiniteScrollSentinel
canLoadMore={canLoadMore}
onLoadMore={() => {
void installedAppsQuery.fetchNextPage({
cancelRefetch: false,
})
}}
preloadDistance={getPreloadDistance}
scrollContainerRef={scrollRef}
/>
<InstalledAppPaginationSkeleton />
{installedAppsQuery.isFetchNextPageError && (
<div
className="absolute inset-0 flex items-center justify-center gap-2 bg-background-body px-2 system-xs-regular text-text-tertiary"
role="alert"
>
<span>{t(($) => $['errorBoundary.title'], { ns: 'common' })}</span>
<Button
loading={installedAppsQuery.isFetchingNextPage}
size="small"
variant="secondary"
onClick={() => {
void installedAppsQuery.fetchNextPage({ cancelRefetch: false })
}}
>
{t(($) => $['operation.retry'], { ns: 'common' })}
</Button>
</div>
)}
</div>
)}
</ScrollAreaContent>
</ScrollAreaViewport>
<ScrollAreaScrollbar>
@@ -1,4 +1,5 @@
import { render, screen } from '@testing-library/react'
import { act, render, screen } from '@testing-library/react'
import { Provider } from 'jotai'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SourceStepContent } from '../source-step'
@@ -14,18 +15,19 @@ const mocks = vi.hoisted(() => {
isPlaceholderData: false,
}
return {
sourceAppsQuery,
useInfiniteScroll: vi.fn(() => ({
rootRef: vi.fn(),
sentinelRef: vi.fn(),
})),
}
return { sourceAppsQuery }
})
vi.mock('@/features/deployments/shared/hooks/use-infinite-scroll', () => ({
useInfiniteScroll: mocks.useInfiniteScroll,
}))
type ObserverRecord = {
callback: IntersectionObserverCallback
options?: IntersectionObserverInit
}
const observers: ObserverRecord[] = []
function getInfiniteScrollObserver() {
return observers.find(({ options }) => options?.rootMargin === '0px 0px 160px 0px')
}
vi.mock('@/features/deployments/create-guide/state/primitives', async () => {
const { atom } = await import('jotai')
@@ -87,10 +89,28 @@ vi.mock('@/features/deployments/create-guide/state/queries', async () => {
describe('SourceStepContent', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.sourceAppsQuery.fetchNextPage.mockReset()
observers.length = 0
vi.stubGlobal(
'IntersectionObserver',
class MockIntersectionObserver {
readonly root = null
readonly rootMargin = ''
readonly scrollMargin = ''
readonly thresholds: ReadonlyArray<number> = []
readonly disconnect = vi.fn()
readonly observe = vi.fn()
readonly takeRecords = () => []
readonly unobserve = vi.fn()
constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
observers.push({ callback, options })
}
},
)
Object.assign(mocks.sourceAppsQuery, {
data: { pages: [{ data: [] }] },
error: null,
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetching: false,
isFetchingNextPage: false,
@@ -99,8 +119,16 @@ describe('SourceStepContent', () => {
})
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('should hide the import DSL option when deployment DSL import is disabled', () => {
render(<SourceStepContent />)
render(
<Provider>
<SourceStepContent />
</Provider>,
)
expect(screen.getByText(/createGuide\.methods\.bindApp\.title/)).toBeInTheDocument()
expect(screen.queryByText(/createGuide\.methods\.importDsl\.title/)).not.toBeInTheDocument()
@@ -129,21 +157,20 @@ describe('SourceStepContent', () => {
hasNextPage: true,
})
render(<SourceStepContent />)
expect(mocks.useInfiniteScroll).toHaveBeenCalledWith(
expect.objectContaining({
fetchNextPage: expect.any(Function),
hasNextPage: expect.any(Boolean),
isFetching: false,
isFetchingNextPage: false,
isLoading: false,
}),
expect.objectContaining({
rootMargin: '0px 0px 160px 0px',
threshold: 0.1,
}),
render(
<Provider>
<SourceStepContent />
</Provider>,
)
act(() => {
getInfiniteScrollObserver()?.callback(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
})
expect(mocks.sourceAppsQuery.fetchNextPage).toHaveBeenCalledWith({ cancelRefetch: false })
expect(
screen.queryByRole('button', { name: /createModal\.loadMoreApps/ }),
).not.toBeInTheDocument()
@@ -10,9 +10,11 @@ import { cn } from '@langgenius/dify-ui/cn'
import { Input } from '@langgenius/dify-ui/input'
import { RadioGroup, RadioItem } from '@langgenius/dify-ui/radio'
import { useAtomValue, useSetAtom } from 'jotai'
import { useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { Uploader } from '@/app/components/app/create-from-dsl-modal/uploader'
import AppIcon from '@/app/components/base/app-icon'
import { InfiniteScrollSentinel } from '@/app/components/base/infinite-scroll-sentinel'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import {
dslFileAtom,
@@ -46,7 +48,6 @@ import { DeploymentStateMessage } from '@/features/deployments/shared/components
import { TitleTooltip } from '@/features/deployments/shared/components/title-tooltip'
import { UnsupportedDslNodesAlert } from '@/features/deployments/shared/components/unsupported-dsl-nodes-alert'
import { isDeploymentDslImportEnabled } from '@/features/deployments/shared/domain/feature-flags'
import { useInfiniteScroll } from '@/features/deployments/shared/hooks/use-infinite-scroll'
import { StepShell } from './layout'
const sourceAppSkeletonKeys = ['first-source-app', 'second-source-app', 'third-source-app']
@@ -211,29 +212,16 @@ function SourceAppList() {
const sourceAppsIsFetchingNextPage = useAtomValue(sourceAppsIsFetchingNextPageAtom)
const sourceAppsIsLoading = useAtomValue(sourceAppsIsLoadingAtom)
const sourceAppsIsPlaceholderData = useAtomValue(sourceAppsIsPlaceholderDataAtom)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const sourceAppsLoading =
sourceAppsIsLoading ||
sourceAppsIsPlaceholderData ||
(sourceAppsIsFetching && sourceApps.length === 0)
const { rootRef, sentinelRef } = useInfiniteScroll<HTMLDivElement>(
{
error: sourceAppsError,
fetchNextPage: sourceAppsFetchNextPage,
hasNextPage: sourceAppsHasNextPage,
isFetching: sourceAppsIsFetching,
isFetchingNextPage: sourceAppsIsFetchingNextPage,
isLoading: sourceAppsIsLoading,
},
{
enabled: !sourceAppsLoading,
rootMargin: '0px 0px 160px 0px',
threshold: 0.1,
},
)
const canLoadMore = !sourceAppsIsFetching && !sourceAppsError
return (
<div
ref={rootRef}
ref={scrollContainerRef}
className="min-h-0 flex-1 overflow-y-auto rounded-lg border border-divider-subtle bg-background-default"
>
{sourceAppsLoading ? (
@@ -257,7 +245,16 @@ function SourceAppList() {
{t(($) => $['createModal.loadingApps'])}
</div>
)}
{sourceAppsHasNextPage && <div ref={sentinelRef} aria-hidden="true" className="h-px" />}
{sourceAppsHasNextPage && (
<InfiniteScrollSentinel
canLoadMore={canLoadMore}
onLoadMore={() => {
void sourceAppsFetchNextPage({ cancelRefetch: false })
}}
preloadDistance={160}
scrollContainerRef={scrollContainerRef}
/>
)}
</div>
)}
</div>
@@ -1,6 +1,7 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, waitFor } from '@testing-library/react'
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Provider } from 'jotai'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SourceAppPicker } from '../source-app-picker'
@@ -26,15 +27,20 @@ const mocks = vi.hoisted(() => {
isLoading: false,
}
return {
sourceAppsQuery,
useInfiniteScroll: vi.fn(() => ({
rootRef: vi.fn(),
sentinelRef: vi.fn(),
})),
}
return { sourceAppsQuery }
})
type ObserverRecord = {
callback: IntersectionObserverCallback
options?: IntersectionObserverInit
}
const observers: ObserverRecord[] = []
function getInfiniteScrollObserver() {
return observers.find(({ options }) => options?.rootMargin === '0px 0px 160px 0px')
}
vi.mock('@/features/deployments/create-release/state', async () => {
const { atom } = await import('jotai')
@@ -55,10 +61,6 @@ vi.mock('@/features/deployments/create-release/state', async () => {
}
})
vi.mock('@/features/deployments/shared/hooks/use-infinite-scroll', () => ({
useInfiniteScroll: mocks.useInfiniteScroll,
}))
function renderSourceAppPicker(disabled: boolean) {
const queryClient = new QueryClient({
defaultOptions: {
@@ -69,19 +71,40 @@ function renderSourceAppPicker(disabled: boolean) {
})
return render(
<QueryClientProvider client={queryClient}>
<SourceAppPicker
value={{ id: 'app-1', name: 'Workflow 1', mode: 'workflow', icon_url: null }}
onChange={() => undefined}
disabled={disabled}
/>
</QueryClientProvider>,
<Provider>
<QueryClientProvider client={queryClient}>
<SourceAppPicker
value={{ id: 'app-1', name: 'Workflow 1', mode: 'workflow', icon_url: null }}
onChange={() => undefined}
disabled={disabled}
/>
</QueryClientProvider>
</Provider>,
)
}
describe('SourceAppPicker', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.sourceAppsQuery.fetchNextPage.mockReset()
observers.length = 0
vi.stubGlobal(
'IntersectionObserver',
class MockIntersectionObserver {
readonly root = null
readonly rootMargin = ''
readonly scrollMargin = ''
readonly thresholds: ReadonlyArray<number> = []
readonly disconnect = vi.fn()
readonly observe = vi.fn()
readonly takeRecords = () => []
readonly unobserve = vi.fn()
constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
observers.push({ callback, options })
}
},
)
Object.assign(mocks.sourceAppsQuery, {
data: {
pages: [
@@ -96,7 +119,6 @@ describe('SourceAppPicker', () => {
],
},
error: null,
fetchNextPage: vi.fn(),
hasNextPage: true,
isFetching: false,
isFetchingNextPage: false,
@@ -104,6 +126,10 @@ describe('SourceAppPicker', () => {
})
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('should disable the switch control when disabled', () => {
renderSourceAppPicker(true)
@@ -117,40 +143,21 @@ describe('SourceAppPicker', () => {
const user = userEvent.setup()
renderSourceAppPicker(false)
expect(mocks.useInfiniteScroll).toHaveBeenCalledWith(
expect.objectContaining({
fetchNextPage: expect.any(Function),
hasNextPage: true,
isFetching: false,
isFetchingNextPage: false,
isLoading: false,
}),
expect.objectContaining({
enabled: false,
rootMargin: '0px 0px 160px 0px',
threshold: 0.1,
}),
)
expect(getInfiniteScrollObserver()).toBeUndefined()
await user.click(screen.getByRole('combobox', { name: 'deployments.versions.sourceAppOption' }))
await waitFor(() => {
expect(mocks.useInfiniteScroll).toHaveBeenLastCalledWith(
expect.objectContaining({
fetchNextPage: expect.any(Function),
hasNextPage: true,
isFetching: false,
isFetchingNextPage: false,
isLoading: false,
}),
expect.objectContaining({
enabled: true,
rootMargin: '0px 0px 160px 0px',
threshold: 0.1,
}),
expect(getInfiniteScrollObserver()).toBeDefined()
})
act(() => {
getInfiniteScrollObserver()?.callback(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
})
expect(mocks.sourceAppsQuery.fetchNextPage).toHaveBeenCalledWith({ cancelRefetch: false })
expect(
screen.queryByRole('button', { name: /createModal\.loadMoreApps/ }),
).not.toBeInTheDocument()
@@ -15,11 +15,11 @@ import {
ComboboxTrigger,
} from '@langgenius/dify-ui/combobox'
import { useAtomValue, useSetAtom } from 'jotai'
import { useState } from 'react'
import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import { InfiniteScrollSentinel } from '@/app/components/base/infinite-scroll-sentinel'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { useInfiniteScroll } from '@/features/deployments/shared/hooks/use-infinite-scroll'
import { TitleTooltip } from '../../shared/components/title-tooltip'
import {
createReleaseSourceAppsAtom,
@@ -152,22 +152,9 @@ export function SourceAppPicker({
const sourceAppsIsFetching = useAtomValue(createReleaseSourceAppsIsFetchingAtom)
const sourceAppsIsFetchingNextPage = useAtomValue(createReleaseSourceAppsIsFetchingNextPageAtom)
const sourceAppsIsLoading = useAtomValue(createReleaseSourceAppsIsLoadingAtom)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const selectedApp = apps.find((app) => app.id === value?.id) ?? null
const { rootRef, sentinelRef } = useInfiniteScroll<HTMLDivElement>(
{
error: sourceAppsError,
fetchNextPage: sourceAppsFetchNextPage,
hasNextPage: sourceAppsHasNextPage,
isFetching: sourceAppsIsFetching,
isFetchingNextPage: sourceAppsIsFetchingNextPage,
isLoading: sourceAppsIsLoading,
},
{
enabled: isShow && !disabled,
rootMargin: '0px 0px 160px 0px',
threshold: 0.1,
},
)
const canLoadMore = isShow && !disabled && !sourceAppsIsFetching && !sourceAppsError
return (
<Combobox<AppPartial>
@@ -227,7 +214,7 @@ export function SourceAppPicker({
/>
</ComboboxInputGroup>
</div>
<div ref={rootRef} className="min-h-0 flex-1 overflow-y-auto p-1">
<div ref={scrollContainerRef} className="min-h-0 flex-1 overflow-y-auto p-1">
{(sourceAppsIsLoading || sourceAppsIsFetchingNextPage) && apps.length === 0 && (
<SourceAppPickerSkeleton />
)}
@@ -242,7 +229,16 @@ export function SourceAppPicker({
{t(($) => $['createModal.loadingApps'])}
</div>
)}
{sourceAppsHasNextPage && <div ref={sentinelRef} aria-hidden="true" className="h-px" />}
{sourceAppsHasNextPage && (
<InfiniteScrollSentinel
canLoadMore={canLoadMore}
onLoadMore={() => {
void sourceAppsFetchNextPage({ cancelRefetch: false })
}}
preloadDistance={160}
scrollContainerRef={scrollContainerRef}
/>
)}
</div>
</div>
</ComboboxContent>
+1 -7
View File
@@ -145,10 +145,6 @@ export const deploymentsListIsFetchingNextPageAtom = selectAtom(
deploymentsListQueryAtom,
(query) => query.isFetchingNextPage,
)
export const deploymentsListIsLoadingAtom = selectAtom(
deploymentsListQueryAtom,
(query) => query.isLoading,
)
const deploymentsListIsErrorAtom = selectAtom(deploymentsListQueryAtom, (query) => query.isError)
export const deploymentsListRowsAtom = atom((get) => {
@@ -158,9 +154,7 @@ export const deploymentsListRowsAtom = atom((get) => {
export const deploymentsListShowSkeletonAtom = atom((get) => {
const pages = get(deploymentsListDataAtom)?.pages ?? []
return (
get(deploymentsListIsLoadingAtom) || (get(deploymentsListIsFetchingAtom) && pages.length === 0)
)
return get(deploymentsListIsFetchingAtom) && pages.length === 0
})
export const deploymentsListShowEmptyStateAtom = atom((get) => {
+15 -14
View File
@@ -6,11 +6,12 @@ import { cn } from '@langgenius/dify-ui/cn'
import { Input } from '@langgenius/dify-ui/input'
import { useAtomValue } from 'jotai'
import { debounce, useQueryState } from 'nuqs'
import { useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { StudioListHeader } from '@/app/components/apps/studio-list-header'
import { InfiniteScrollSentinel } from '@/app/components/base/infinite-scroll-sentinel'
import { SkeletonRectangle } from '@/app/components/base/skeleton'
import { DeploymentEmptyState, DeploymentStateMessage } from '../../shared/components/empty-state'
import { useInfiniteScroll } from '../../shared/hooks/use-infinite-scroll'
import {
deploymentsListErrorAtom,
deploymentsListFetchNextPageAtom,
@@ -18,7 +19,6 @@ import {
deploymentsListHasNextPageAtom,
deploymentsListIsFetchingAtom,
deploymentsListIsFetchingNextPageAtom,
deploymentsListIsLoadingAtom,
deploymentsListRowsAtom,
deploymentsListShowEmptyStateAtom,
deploymentsListShowErrorStateAtom,
@@ -172,24 +172,17 @@ export function DeploymentsListShell() {
const deploymentsListHasNextPage = useAtomValue(deploymentsListHasNextPageAtom)
const deploymentsListIsFetching = useAtomValue(deploymentsListIsFetchingAtom)
const deploymentsListIsFetchingNextPage = useAtomValue(deploymentsListIsFetchingNextPageAtom)
const deploymentsListIsLoading = useAtomValue(deploymentsListIsLoadingAtom)
const appInstanceSummaries = useAtomValue(deploymentsListRowsAtom)
const showSkeleton = useAtomValue(deploymentsListShowSkeletonAtom)
const showErrorState = useAtomValue(deploymentsListShowErrorStateAtom)
const showEmptyState = useAtomValue(deploymentsListShowEmptyStateAtom)
const { rootRef, sentinelRef } = useInfiniteScroll<HTMLDivElement>({
error: deploymentsListError,
fetchNextPage: deploymentsListFetchNextPage,
hasNextPage: deploymentsListHasNextPage,
isFetching: deploymentsListIsFetching,
isFetchingNextPage: deploymentsListIsFetchingNextPage,
isLoading: deploymentsListIsLoading,
})
const scrollContainerRef = useRef<HTMLDivElement>(null)
const canLoadMore =
deploymentsListHasNextPage && !deploymentsListIsFetching && !deploymentsListError
return (
<div
ref={rootRef}
ref={scrollContainerRef}
className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body"
>
<DeploymentsListControls />
@@ -211,7 +204,15 @@ export function DeploymentsListShell() {
))
)}
{deploymentsListIsFetchingNextPage && <DeploymentsListSkeleton />}
<div ref={sentinelRef} aria-hidden="true" className="col-span-full h-px" />
<InfiniteScrollSentinel
canLoadMore={canLoadMore}
className="col-span-full"
onLoadMore={() => {
void deploymentsListFetchNextPage({ cancelRefetch: false })
}}
preloadDistance={300}
scrollContainerRef={scrollContainerRef}
/>
</div>
</div>
)
@@ -1,206 +0,0 @@
import type { InfiniteScrollQueryResult, UseInfiniteScrollOptions } from '../use-infinite-scroll'
import { act, render } from '@testing-library/react'
import { createElement } from 'react'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { useInfiniteScroll } from '../use-infinite-scroll'
let intersectionCallback: IntersectionObserverCallback | undefined
let intersectionOptions: IntersectionObserverInit | undefined
const observe = vi.fn()
const disconnect = vi.fn()
const unobserve = vi.fn()
const originalIntersectionObserver = globalThis.IntersectionObserver
class MockIntersectionObserver implements IntersectionObserver {
readonly root: Element | Document | null
readonly rootMargin: string
readonly scrollMargin = ''
readonly thresholds: ReadonlyArray<number>
constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
intersectionCallback = callback
intersectionOptions = options
this.root = options?.root ?? null
this.rootMargin = options?.rootMargin ?? ''
this.thresholds = Array.isArray(options?.threshold)
? options.threshold
: [options?.threshold ?? 0]
}
observe = observe
unobserve = unobserve
disconnect = disconnect
takeRecords = () => []
}
function TestInfiniteScroll({
options,
query,
}: {
options?: UseInfiniteScrollOptions
query: InfiniteScrollQueryResult
}) {
const { rootRef, sentinelRef } = useInfiniteScroll<HTMLDivElement, HTMLDivElement>(query, options)
return createElement(
'div',
{ ref: rootRef, 'data-testid': 'scroll-root' },
createElement('div', { ref: sentinelRef, 'data-testid': 'scroll-sentinel' }),
)
}
function createInfiniteScrollQuery(
overrides: Partial<InfiniteScrollQueryResult> = {},
): InfiniteScrollQueryResult {
return {
error: null,
fetchNextPage: vi.fn(() => Promise.resolve()),
hasNextPage: true,
isFetching: false,
isFetchingNextPage: false,
isLoading: false,
...overrides,
}
}
function renderInfiniteScroll(
query: InfiniteScrollQueryResult,
options?: UseInfiniteScrollOptions,
) {
const view = render(createElement(TestInfiniteScroll, { options, query }))
return {
...view,
root: view.getByTestId('scroll-root'),
sentinel: view.getByTestId('scroll-sentinel'),
}
}
function triggerIntersection(isIntersecting: boolean) {
if (!intersectionCallback)
throw new Error('Expected IntersectionObserver callback to be registered')
intersectionCallback(
[{ isIntersecting } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
}
describe('useInfiniteScroll', () => {
beforeEach(() => {
vi.clearAllMocks()
intersectionCallback = undefined
intersectionOptions = undefined
globalThis.IntersectionObserver =
MockIntersectionObserver as unknown as typeof IntersectionObserver
})
afterAll(() => {
globalThis.IntersectionObserver = originalIntersectionObserver
})
// The hook owns both refs and wires the sentinel to the scroll container root.
it('should observe the sentinel with the scroll root', () => {
const query = createInfiniteScrollQuery()
const { root, sentinel } = renderInfiniteScroll(query)
expect(observe).toHaveBeenCalledWith(sentinel)
expect(intersectionOptions?.root).toBe(root)
expect(intersectionOptions?.rootMargin).toBe('0px 0px 300px 0px')
expect(intersectionOptions?.threshold).toBe(0)
})
// Pagination starts when the sentinel enters the configured observer area.
it('should load more when the sentinel intersects', () => {
const query = createInfiniteScrollQuery()
renderInfiniteScroll(query)
triggerIntersection(true)
expect(query.fetchNextPage).toHaveBeenCalledTimes(1)
expect(query.fetchNextPage).toHaveBeenCalledWith({ cancelRefetch: false })
})
// Non-intersecting observer entries should not advance the query.
it('should not load more when the sentinel is outside the observer area', () => {
const query = createInfiniteScrollQuery()
renderInfiniteScroll(query)
triggerIntersection(false)
expect(query.fetchNextPage).not.toHaveBeenCalled()
})
// Query state should gate observer registration so stale or duplicate loads do not fire.
it.each([
['has no next page', { hasNextPage: false }],
['is loading', { isLoading: true }],
['is fetching the next page', { isFetchingNextPage: true }],
['is fetching and guarded', { isFetching: true }],
['has an error', { error: new Error('load failed') }],
] as const)('should not observe when the query %s', (_label, overrides) => {
const query = createInfiniteScrollQuery(overrides)
renderInfiniteScroll(query)
expect(observe).not.toHaveBeenCalled()
expect(query.fetchNextPage).not.toHaveBeenCalled()
})
// Options are passed through to IntersectionObserver and fetchNextPage.
it('should use custom observer and fetch options', () => {
const query = createInfiniteScrollQuery()
renderInfiniteScroll(query, {
cancelRefetch: true,
rootMargin: '0px 0px 160px 0px',
threshold: 0.1,
})
triggerIntersection(true)
expect(intersectionOptions?.rootMargin).toBe('0px 0px 160px 0px')
expect(intersectionOptions?.threshold).toBe(0.1)
expect(query.fetchNextPage).toHaveBeenCalledWith({ cancelRefetch: true })
})
// Window mode observes against the viewport instead of the local scroll container.
it('should use the viewport root when useWindow is enabled', () => {
const query = createInfiniteScrollQuery()
renderInfiniteScroll(query, { useWindow: true })
expect(intersectionOptions?.root).toBeNull()
})
// The local lock avoids repeated calls while TanStack Query is still resolving fetchNextPage.
it('should not request another page while the previous request is pending', async () => {
let resolveFetch: (value?: unknown) => void = () => undefined
const fetchNextPage = vi.fn(
() =>
new Promise((resolve) => {
resolveFetch = resolve
}),
)
const query = createInfiniteScrollQuery({ fetchNextPage })
renderInfiniteScroll(query)
triggerIntersection(true)
triggerIntersection(true)
expect(fetchNextPage).toHaveBeenCalledTimes(1)
await act(async () => {
resolveFetch()
await Promise.resolve()
})
})
// Cleanup matters when the list unmounts or query state recreates the observer.
it('should disconnect the observer on unmount', () => {
const query = createInfiniteScrollQuery()
const { unmount } = renderInfiniteScroll(query)
unmount()
expect(disconnect).toHaveBeenCalledTimes(1)
})
})
@@ -1,207 +0,0 @@
import type { RefCallback } from 'react'
import { useCallback, useEffect, useRef } from 'react'
type FetchNextPageOptions = {
cancelRefetch?: boolean
}
export type InfiniteScrollQueryResult = {
error?: unknown
fetchNextPage: (options?: FetchNextPageOptions) => Promise<unknown> | unknown
hasNextPage?: boolean
isFetching?: boolean
isFetchingNextPage: boolean
isLoading?: boolean
}
export type UseInfiniteScrollOptions = {
cancelRefetch?: boolean
enabled?: boolean
guardOnFetching?: boolean
rootMargin?: string
threshold?: number | number[]
useWindow?: boolean
}
type UseInfiniteScrollResult<TRoot extends Element, TTarget extends Element> = {
rootRef: RefCallback<TRoot>
sentinelRef: RefCallback<TTarget>
}
type ObservedTarget<TRoot extends Element, TTarget extends Element> = {
rootEl: TRoot | null
rootMargin: string
sentinelEl: TTarget
threshold: number | number[]
useWindow: boolean
}
export function useInfiniteScroll<
TRoot extends Element = HTMLDivElement,
TTarget extends Element = HTMLDivElement,
>(
query: InfiniteScrollQueryResult,
options: UseInfiniteScrollOptions = {},
): UseInfiniteScrollResult<TRoot, TTarget> {
const {
cancelRefetch = false,
enabled = true,
guardOnFetching = true,
rootMargin = '0px 0px 300px 0px',
threshold = 0,
useWindow = false,
} = options
const observerRef = useRef<IntersectionObserver | null>(null)
const observedTargetRef = useRef<ObservedTarget<TRoot, TTarget> | null>(null)
const rootElRef = useRef<TRoot | null>(null)
const sentinelElRef = useRef<TTarget | null>(null)
const loadingLockRef = useRef(false)
const latestRef = useRef({
cancelRefetch,
enabled,
error: query.error,
fetchNextPage: query.fetchNextPage,
guardOnFetching,
hasNextPage: Boolean(query.hasNextPage),
isFetching: query.isFetching ?? false,
isFetchingNextPage: query.isFetchingNextPage,
isLoading: query.isLoading ?? false,
})
latestRef.current = {
cancelRefetch,
enabled,
error: query.error,
fetchNextPage: query.fetchNextPage,
guardOnFetching,
hasNextPage: Boolean(query.hasNextPage),
isFetching: query.isFetching ?? false,
isFetchingNextPage: query.isFetchingNextPage,
isLoading: query.isLoading ?? false,
}
const canLoad =
enabled &&
Boolean(query.hasNextPage) &&
!query.isFetchingNextPage &&
!(query.isLoading ?? false) &&
!query.error &&
!(guardOnFetching && (query.isFetching ?? false))
const disconnectObserver = useCallback(() => {
observerRef.current?.disconnect()
observerRef.current = null
observedTargetRef.current = null
}, [])
const connectObserver = useCallback(() => {
if (!canLoad) {
disconnectObserver()
return
}
const rootEl = rootElRef.current
const sentinelEl = sentinelElRef.current
if (!sentinelEl) {
disconnectObserver()
return
}
if (!useWindow && !rootEl) {
disconnectObserver()
return
}
if (typeof IntersectionObserver === 'undefined') {
disconnectObserver()
return
}
const observedTarget = observedTargetRef.current
if (
observerRef.current &&
observedTarget?.rootEl === rootEl &&
observedTarget.sentinelEl === sentinelEl &&
observedTarget.rootMargin === rootMargin &&
observedTarget.threshold === threshold &&
observedTarget.useWindow === useWindow
) {
return
}
disconnectObserver()
const observer = new IntersectionObserver(
([entry]) => {
const latest = latestRef.current
if (!entry?.isIntersecting) return
if (
!latest.enabled ||
!latest.hasNextPage ||
latest.isLoading ||
latest.isFetchingNextPage ||
latest.error ||
(latest.guardOnFetching && latest.isFetching) ||
loadingLockRef.current
) {
return
}
loadingLockRef.current = true
const nextPage = latest.fetchNextPage({
cancelRefetch: latest.cancelRefetch,
})
void Promise.resolve(nextPage).finally(() => {
loadingLockRef.current = false
})
},
{
root: useWindow ? null : rootEl,
rootMargin,
threshold,
},
)
observer.observe(sentinelEl)
observerRef.current = observer
observedTargetRef.current = {
rootEl,
rootMargin,
sentinelEl,
threshold,
useWindow,
}
}, [canLoad, disconnectObserver, rootMargin, threshold, useWindow])
const rootRef = useCallback(
(node: TRoot | null) => {
rootElRef.current = node
connectObserver()
},
[connectObserver],
)
const sentinelRef = useCallback(
(node: TTarget | null) => {
sentinelElRef.current = node
connectObserver()
},
[connectObserver],
)
useEffect(() => {
connectObserver()
}, [connectObserver])
return {
rootRef,
sentinelRef,
}
}