diff --git a/web/app/components/workflow/run/__tests__/index.spec.tsx b/web/app/components/workflow/run/__tests__/index.spec.tsx index eb07b6fd430..cf9aaa96ff2 100644 --- a/web/app/components/workflow/run/__tests__/index.spec.tsx +++ b/web/app/components/workflow/run/__tests__/index.spec.tsx @@ -1,6 +1,6 @@ import type { WorkflowRunDetailResponse } from '@/models/log' import type { NodeTracing, NodeTracingListResponse } from '@/types/workflow' -import { screen, waitFor, within } from '@testing-library/react' +import { act, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { renderWorkflowComponent } from '../../__tests__/workflow-test-env' import { BlockEnum, NodeRunningStatus } from '../../types' @@ -246,4 +246,198 @@ describe('RunPanel', () => { expect(mockFetchRunDetail).toHaveBeenCalledTimes(activeTab === 'RESULT' ? 2 : 1) }, ) + it('keeps the new record and selected tab when an old detail response arrives late', async () => { + const user = userEvent.setup() + const handleResult = vi.fn() + let resolveOldResponse: (detail: WorkflowRunDetailResponse) => void = () => {} + mockFetchRunDetail + .mockReturnValueOnce( + new Promise((resolve) => { + resolveOldResponse = resolve + }), + ) + .mockResolvedValue(createRunDetail({ id: 'new-run', outputs: 'New result' })) + const { rerender } = renderWorkflowComponent( + , + ) + await user.click(screen.getByRole('tab', { name: 'runLog.tracing' })) + rerender( + , + ) + expect(screen.getByRole('tab', { name: 'runLog.tracing' })).toHaveAttribute( + 'aria-selected', + 'true', + ) + await screen.findByText('Trace Node') + await act(async () => { + resolveOldResponse(createRunDetail({ id: 'old-run', outputs: 'Old result' })) + }) + expect(handleResult).toHaveBeenCalledTimes(1) + expect(handleResult).toHaveBeenCalledWith(expect.objectContaining({ id: 'new-run' })) + await user.click(screen.getByRole('tab', { name: 'runLog.result' })) + expect(((await screen.findByTestId('monaco-editor')) as HTMLTextAreaElement).value).toContain( + 'New result', + ) + }) + + it('clears the previous result while the destination record is loading and ignores old failures', async () => { + const user = userEvent.setup() + const { rerender } = renderWorkflowComponent( + , + ) + await screen.findByTestId('monaco-editor') + let rejectRefresh: (error: Error) => void = () => {} + mockFetchRunDetail + .mockReturnValueOnce( + new Promise((_, reject) => { + rejectRefresh = reject + }), + ) + .mockResolvedValue(createRunDetail({ id: 'next', outputs: 'Next result' })) + await user.click(screen.getByRole('tab', { name: 'runLog.result' })) + rerender() + expect(screen.queryByTestId('monaco-editor')).not.toBeInTheDocument() + await screen.findByTestId('monaco-editor') + await act(async () => { + rejectRefresh(new Error('Old refresh failed')) + }) + expect(mockToastError).not.toHaveBeenCalled() + expect((screen.getByTestId('monaco-editor') as HTMLTextAreaElement).value).toContain( + 'Next result', + ) + }) + + it('ignores tracing that completes after its record has been replaced', async () => { + let resolveOldTrace: (value: NodeTracingListResponse) => void = () => {} + mockFetchTracingList + .mockReturnValueOnce( + new Promise((resolve) => { + resolveOldTrace = resolve + }), + ) + .mockResolvedValue({ data: [createTracingNode({ title: 'New trace' })] }) + const { rerender } = renderWorkflowComponent( + , + ) + await waitFor(() => expect(mockFetchTracingList).toHaveBeenCalledTimes(1)) + rerender( + , + ) + await screen.findByText('New trace') + await act(async () => { + resolveOldTrace({ data: [createTracingNode({ title: 'Old trace' })] }) + }) + expect(screen.getByText('New trace')).toBeInTheDocument() + expect(screen.queryByText('Old trace')).not.toBeInTheDocument() + }) + it('does not restart the record request when its result callback changes', async () => { + const user = userEvent.setup() + const firstCallback = vi.fn() + const nextCallback = vi.fn() + const { rerender } = renderWorkflowComponent( + , + ) + await screen.findByTestId('monaco-editor') + rerender( + , + ) + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument() + expect(mockFetchRunDetail).toHaveBeenCalledTimes(1) + await user.click(screen.getByRole('tab', { name: 'runLog.result' })) + await waitFor(() => expect(nextCallback).toHaveBeenCalledTimes(1)) + expect(firstCallback).toHaveBeenCalledTimes(1) + }) + it('keeps the latest refresh when requests for the same record resolve out of order', async () => { + const user = userEvent.setup() + renderWorkflowComponent( + , + ) + await screen.findByTestId('monaco-editor') + let resolveEarlierRefresh: (detail: WorkflowRunDetailResponse) => void = () => {} + mockFetchRunDetail + .mockReturnValueOnce( + new Promise((resolve) => { + resolveEarlierRefresh = resolve + }), + ) + .mockResolvedValue(createRunDetail({ outputs: 'Latest result' })) + await user.click(screen.getByRole('tab', { name: 'runLog.result' })) + await user.click(screen.getByRole('tab', { name: 'runLog.result' })) + await waitFor(() => + expect((screen.getByTestId('monaco-editor') as HTMLTextAreaElement).value).toContain( + 'Latest result', + ), + ) + await act(async () => { + resolveEarlierRefresh(createRunDetail({ outputs: 'Outdated result' })) + }) + expect((screen.getByTestId('monaco-editor') as HTMLTextAreaElement).value).toContain( + 'Latest result', + ) + }) + it('keeps the latest tracing refresh and suppresses an older failed detail refresh', async () => { + const user = userEvent.setup() + renderWorkflowComponent( + , + ) + await screen.findByTestId('monaco-editor') + let rejectEarlierDetail: (error: Error) => void = () => {} + let resolveEarlierTrace: (value: NodeTracingListResponse) => void = () => {} + mockFetchRunDetail.mockReturnValueOnce( + new Promise((_, reject) => { + rejectEarlierDetail = reject + }), + ) + mockFetchTracingList + .mockReturnValueOnce( + new Promise((resolve) => { + resolveEarlierTrace = resolve + }), + ) + .mockResolvedValue({ data: [createTracingNode({ title: 'Latest trace' })] }) + await user.click(screen.getByRole('tab', { name: 'runLog.result' })) + await user.click(screen.getByRole('tab', { name: 'runLog.result' })) + await waitFor(() => expect(mockFetchRunDetail).toHaveBeenCalledTimes(3)) + await act(async () => { + rejectEarlierDetail(new Error('Outdated failure')) + resolveEarlierTrace({ data: [createTracingNode({ title: 'Outdated trace' })] }) + }) + mockFetchTracingList.mockReturnValue(new Promise(() => {})) + await user.click(screen.getByRole('tab', { name: 'runLog.tracing' })) + expect(screen.getByText('Latest trace')).toBeInTheDocument() + expect(screen.queryByText('Outdated trace')).not.toBeInTheDocument() + expect(mockToastError).not.toHaveBeenCalled() + }) + + it('finishes loading from a newer refresh without waiting for the initial request', async () => { + const user = userEvent.setup() + mockFetchRunDetail + .mockReturnValueOnce(new Promise(() => {})) + .mockResolvedValue(createRunDetail({ outputs: 'Refreshed result' })) + renderWorkflowComponent( + , + ) + expect(screen.getByRole('progressbar')).toBeInTheDocument() + await user.click(screen.getByRole('tab', { name: 'runLog.result' })) + expect(((await screen.findByTestId('monaco-editor')) as HTMLTextAreaElement).value).toContain( + 'Refreshed result', + ) + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument() + }) }) diff --git a/web/app/components/workflow/run/index.tsx b/web/app/components/workflow/run/index.tsx index c1447366c26..77870e8cb64 100644 --- a/web/app/components/workflow/run/index.tsx +++ b/web/app/components/workflow/run/index.tsx @@ -3,7 +3,7 @@ import type { FC } from 'react' import type { WorkflowRunDetailResponse } from '@/models/log' import type { NodeTracing } from '@/types/workflow' import { Tabs, TabsList, TabsPanel, TabsTab } from '@langgenius/dify-ui/tabs' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import { WorkflowRunningStatus } from '@/app/components/workflow/types' @@ -23,19 +23,63 @@ type RunProps = { tracingListUrl: string } +type RunTab = NonNullable +type RequestLifetime = { + active: boolean + detailRequest: number + tracingRequest: number + detailSettled: boolean + tracingSettled: boolean +} + const RunPanel: FC = ({ - hideResult, activeTab = 'RESULT', + hideResult, getResultCallback, runDetailUrl, tracingListUrl, }) => { + const [currentTab, setCurrentTab] = useState(activeTab) + const isListening = useStore((s) => s.isListening) + + useEffect(() => { + if (isListening) setCurrentTab('DETAIL') + }, [isListening]) + + return ( + + ) +} + +type RunSessionProps = Omit & { + currentTab: RunTab + onTabChange: (tab: RunTab) => void + isListening: boolean +} + +function RunSession({ + hideResult, + getResultCallback, + runDetailUrl, + tracingListUrl, + currentTab, + onTabChange, + isListening, +}: RunSessionProps) { const { t } = useTranslation(['runLog']) - const [currentTab, setCurrentTab] = useState(activeTab) - const [loading, setLoading] = useState(true) + const [loading, setLoading] = useState(true) const [runDetail, setRunDetail] = useState() const [list, setList] = useState([]) - const isListening = useStore((s) => s.isListening) + const requestLifetimeRef = useRef(null) const executor = useMemo(() => { if (runDetail?.created_by_role === 'account') return runDetail.created_by_account?.name || '' @@ -44,49 +88,72 @@ const RunPanel: FC = ({ return 'N/A' }, [runDetail]) - const getResult = useCallback(async () => { - try { - const res = await fetchRunDetail(runDetailUrl) - setRunDetail(res) - if (getResultCallback) getResultCallback(res) - } catch (err) { - toast.error(`${err}`) - } - }, [getResultCallback, runDetailUrl]) + const getResult = useCallback( + async (lifetime: RequestLifetime) => { + const request = ++lifetime.detailRequest + try { + const res = await fetchRunDetail(runDetailUrl) + if (!lifetime.active || request !== lifetime.detailRequest) return + setRunDetail(res) + if (getResultCallback) getResultCallback(res) + } catch (err) { + if (lifetime.active && request === lifetime.detailRequest) toast.error(`${err}`) + } finally { + if (lifetime.active && request === lifetime.detailRequest) { + lifetime.detailSettled = true + if (lifetime.tracingSettled) setLoading(false) + } + } + }, + [getResultCallback, runDetailUrl], + ) - const getTracingList = useCallback(async () => { - try { - const { data: nodeList } = await fetchTracingList({ - url: tracingListUrl, - }) - setList(nodeList) - } catch (err) { - toast.error(`${err}`) - } - }, [tracingListUrl]) + const getTracingList = useCallback( + async (lifetime: RequestLifetime) => { + const request = ++lifetime.tracingRequest + try { + const { data: nodeList } = await fetchTracingList({ + url: tracingListUrl, + }) + if (lifetime.active && request === lifetime.tracingRequest) setList(nodeList) + } catch (err) { + if (lifetime.active && request === lifetime.tracingRequest) toast.error(`${err}`) + } finally { + if (lifetime.active && request === lifetime.tracingRequest) { + lifetime.tracingSettled = true + if (lifetime.detailSettled) setLoading(false) + } + } + }, + [tracingListUrl], + ) - const getData = useCallback(async () => { - setLoading(true) - await getResult() - await getTracingList() - setLoading(false) - }, [getResult, getTracingList]) - - const switchTab = async (tab: string) => { - setCurrentTab(tab) - if (tab === 'RESULT') { - if (runDetailUrl) await getResult() - } - if (tracingListUrl) await getTracingList() + const switchTab = (tab: RunTab) => { + onTabChange(tab) + const lifetime = requestLifetimeRef.current + if (!lifetime?.active) return + if (tab === 'RESULT' && runDetailUrl) void getResult(lifetime) + if (tracingListUrl) void getTracingList(lifetime) } - useEffect(() => { - if (isListening) setCurrentTab('DETAIL') - }, [isListening]) + const loadRecord = useEffectEvent((lifetime: RequestLifetime) => { + void getResult(lifetime) + void getTracingList(lifetime) + }) useEffect(() => { - // fetch data - if (runDetailUrl && tracingListUrl) getData() + const lifetime: RequestLifetime = { + active: true, + detailRequest: 0, + tracingRequest: 0, + detailSettled: false, + tracingSettled: false, + } + requestLifetimeRef.current = lifetime + if (runDetailUrl && tracingListUrl) loadRecord(lifetime) + return () => { + lifetime.active = false + } }, [runDetailUrl, tracingListUrl]) const [height, setHeight] = useState(0) @@ -104,7 +171,7 @@ const RunPanel: FC = ({ switchTab(value as string)} + onValueChange={(value) => switchTab(value as RunTab)} > {/* tab */}