Changes before error encountered

Co-authored-by: hyoban <38493346+hyoban@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-03-18 12:07:32 +00:00
parent 3a64db3d60
commit 039b448b90
4 changed files with 88 additions and 19 deletions

View File

@ -1541,7 +1541,7 @@ describe('AppSelector', () => {
it('should manage isLoadingMore state during load more', () => {
mockHasNextPage = true
mockFetchNextPage.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)))
mockFetchNextPage.mockResolvedValue(undefined)
renderWithQueryClient(<AppSelector {...defaultProps} />)
@ -1769,7 +1769,10 @@ describe('AppSelector', () => {
it('should not call fetchNextPage when isLoadingMore is true', async () => {
mockHasNextPage = true
mockIsFetchingNextPage = false
mockFetchNextPage.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 1000)))
let resolveFetchNextPage: (() => void) | undefined
mockFetchNextPage.mockImplementation(() => new Promise<void>((resolve) => {
resolveFetchNextPage = resolve
}))
renderWithQueryClient(<AppSelector {...defaultProps} />)
@ -1780,8 +1783,13 @@ describe('AppSelector', () => {
// Trigger intersection - this starts loading
triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry])
triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry])
expect(mockFetchNextPage).toHaveBeenCalledTimes(1)
await act(async () => {
resolveFetchNextPage?.()
})
})
it('should skip handleLoadMore when isFetchingNextPage is true', async () => {
@ -1825,8 +1833,10 @@ describe('AppSelector', () => {
it('should return early from handleLoadMore when isLoadingMore is true', async () => {
mockHasNextPage = true
mockIsFetchingNextPage = false
// Make fetchNextPage slow to keep isLoadingMore true
mockFetchNextPage.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 5000)))
let resolveFetchNextPage: (() => void) | undefined
mockFetchNextPage.mockImplementation(() => new Promise<void>((resolve) => {
resolveFetchNextPage = resolve
}))
renderWithQueryClient(<AppSelector {...defaultProps} />)
@ -1843,6 +1853,10 @@ describe('AppSelector', () => {
// Still only 1 call because isLoadingMore blocks it
expect(mockFetchNextPage).toHaveBeenCalledTimes(1)
await act(async () => {
resolveFetchNextPage?.()
})
})
it('should reset isLoadingMore via setTimeout after fetchNextPage resolves', async () => {

View File

@ -54,6 +54,7 @@ const AppPicker: FC<Props> = ({
const observerTarget = useRef<HTMLDivElement>(null)
const observerRef = useRef<IntersectionObserver | null>(null)
const loadingRef = useRef(false)
const loadingResetTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const handleIntersection = useCallback((entries: IntersectionObserverEntry[]) => {
const target = entries[0]
@ -63,8 +64,11 @@ const AppPicker: FC<Props> = ({
loadingRef.current = true
onLoadMore()
// Reset loading state
setTimeout(() => {
if (loadingResetTimeoutRef.current)
clearTimeout(loadingResetTimeoutRef.current)
loadingResetTimeoutRef.current = setTimeout(() => {
loadingRef.current = false
loadingResetTimeoutRef.current = null
}, 500)
}, [hasMore, isLoading, onLoadMore])
@ -116,6 +120,10 @@ const AppPicker: FC<Props> = ({
observerRef.current.disconnect()
observerRef.current = null
}
if (loadingResetTimeoutRef.current) {
clearTimeout(loadingResetTimeoutRef.current)
loadingResetTimeoutRef.current = null
}
mutationObserver?.disconnect()
}
}, [isShow, handleIntersection])

View File

@ -6,7 +6,7 @@ import type {
import type { FC } from 'react'
import type { App } from '@/types/app'
import * as React from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
@ -50,6 +50,7 @@ const AppSelector: FC<Props> = ({
const [isShow, onShowChange] = useState(false)
const [searchText, setSearchText] = useState('')
const [isLoadingMore, setIsLoadingMore] = useState(false)
const loadingMoreTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const {
data,
@ -106,12 +107,24 @@ const AppSelector: FC<Props> = ({
}
finally {
// Add a small delay to ensure state updates are complete
setTimeout(() => {
if (loadingMoreTimeoutRef.current)
clearTimeout(loadingMoreTimeoutRef.current)
loadingMoreTimeoutRef.current = setTimeout(() => {
setIsLoadingMore(false)
loadingMoreTimeoutRef.current = null
}, 300)
}
}, [isLoadingMore, isFetchingNextPage, hasMore, fetchNextPage])
useEffect(() => {
return () => {
if (loadingMoreTimeoutRef.current) {
clearTimeout(loadingMoreTimeoutRef.current)
loadingMoreTimeoutRef.current = null
}
}
}, [])
const handleTriggerClick = () => {
if (disabled)
return

View File

@ -1,5 +1,6 @@
import type { EnvironmentVariable } from '@/app/components/workflow/types'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
import Conversion from '../conversion'
@ -347,6 +348,47 @@ vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({
),
}))
vi.mock('@/app/components/base/modal', () => ({
default: ({ children }: { children: React.ReactNode }) => (
<div data-testid="modal" role="dialog">{children}</div>
),
}))
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
}))
vi.mock('@/app/components/base/app-icon-picker', () => ({
default: function MockAppIconPicker({
onSelect,
onClose,
}: {
onSelect: (selection: { type: 'emoji' | 'image', icon?: string, background?: string, url?: string }) => void
onClose: () => void
}) {
const [pendingSelection, setPendingSelection] = React.useState<{ type: 'emoji' | 'image', icon?: string, background?: string, url?: string } | null>(null)
return (
<div data-testid="app-icon-picker">
<button onClick={() => setPendingSelection({ type: 'emoji', icon: '🤖', background: '#FFEAD5' })}>
iconPicker.emojiOption
</button>
<button onClick={() => setPendingSelection({ type: 'image', url: '/icon.png' })}>
iconPicker.image
</button>
<button onClick={() => pendingSelection && onSelect(pendingSelection)}>
iconPicker.ok
</button>
<button onClick={onClose}>
iconPicker.cancel
</button>
</div>
)
},
}))
// Silence expected console.error from Dialog/Modal rendering
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {})
@ -708,10 +750,7 @@ describe('PublishAsKnowledgePipelineModal', () => {
const appIcon = getAppIcon()
fireEvent.click(appIcon)
// Click the first emoji in the grid (search full document since Dialog uses portal)
const gridEmojis = document.querySelectorAll('.grid em-emoji')
expect(gridEmojis.length).toBeGreaterThan(0)
fireEvent.click(gridEmojis[0].parentElement!.parentElement!)
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.emojiOption/ }))
// Click OK to confirm selection
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
@ -728,9 +767,7 @@ describe('PublishAsKnowledgePipelineModal', () => {
const appIcon = getAppIcon()
fireEvent.click(appIcon)
// Switch to image tab
const imageTab = screen.getByRole('button', { name: /iconPicker\.image/ })
fireEvent.click(imageTab)
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.image/ }))
// Picker should still be open
expect(screen.getByRole('button', { name: /iconPicker\.ok/ })).toBeInTheDocument()
@ -1031,11 +1068,8 @@ describe('Integration Tests', () => {
// Open picker and select an emoji
const appIcon = getAppIcon()
fireEvent.click(appIcon)
const gridEmojis = document.querySelectorAll('.grid em-emoji')
if (gridEmojis.length > 0) {
fireEvent.click(gridEmojis[0].parentElement!.parentElement!)
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
}
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.emojiOption/ }))
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
fireEvent.click(screen.getByRole('button', { name: /workflow\.common\.publish/i }))