mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
refactor(web): unify infinite scroll sentinels (#40188)
This commit is contained in:
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)
|
||||
})
|
||||
})
|
||||
@@ -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)} />
|
||||
}
|
||||
-95
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user