fix(workflow): isolate run records while preserving selected tabs (#43263)

This commit is contained in:
yyh authored and GitHub committed 2026-09-30 04:35:56 +00:00
1 parent df016cd309
commit 2f6b14525e
2 files changed
+305 -44

No files matched your search

@@ -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<WorkflowRunDetailResponse>((resolve) => {
resolveOldResponse = resolve
}),
)
.mockResolvedValue(createRunDetail({ id: 'new-run', outputs: 'New result' }))
const { rerender } = renderWorkflowComponent(
<RunPanel
runDetailUrl="/runs/old-run"
tracingListUrl="/runs/old-run/tracing"
getResultCallback={handleResult}
/>,
)
await user.click(screen.getByRole('tab', { name: 'runLog.tracing' }))
rerender(
<RunPanel
runDetailUrl="/runs/new-run"
tracingListUrl="/runs/new-run/tracing"
getResultCallback={handleResult}
/>,
)
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(
<RunPanel runDetailUrl="/runs/first" tracingListUrl="/runs/first/tracing" />,
)
await screen.findByTestId('monaco-editor')
let rejectRefresh: (error: Error) => void = () => {}
mockFetchRunDetail
.mockReturnValueOnce(
new Promise<WorkflowRunDetailResponse>((_, reject) => {
rejectRefresh = reject
}),
)
.mockResolvedValue(createRunDetail({ id: 'next', outputs: 'Next result' }))
await user.click(screen.getByRole('tab', { name: 'runLog.result' }))
rerender(<RunPanel runDetailUrl="/runs/next" tracingListUrl="/runs/next/tracing" />)
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<NodeTracingListResponse>((resolve) => {
resolveOldTrace = resolve
}),
)
.mockResolvedValue({ data: [createTracingNode({ title: 'New trace' })] })
const { rerender } = renderWorkflowComponent(
<RunPanel activeTab="TRACING" runDetailUrl="/runs/old" tracingListUrl="/runs/old/tracing" />,
)
await waitFor(() => expect(mockFetchTracingList).toHaveBeenCalledTimes(1))
rerender(
<RunPanel activeTab="TRACING" runDetailUrl="/runs/new" tracingListUrl="/runs/new/tracing" />,
)
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(
<RunPanel
runDetailUrl="/runs/record"
tracingListUrl="/runs/record/tracing"
getResultCallback={firstCallback}
/>,
)
await screen.findByTestId('monaco-editor')
rerender(
<RunPanel
runDetailUrl="/runs/record"
tracingListUrl="/runs/record/tracing"
getResultCallback={nextCallback}
/>,
)
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(
<RunPanel runDetailUrl="/runs/current" tracingListUrl="/runs/current/tracing" />,
)
await screen.findByTestId('monaco-editor')
let resolveEarlierRefresh: (detail: WorkflowRunDetailResponse) => void = () => {}
mockFetchRunDetail
.mockReturnValueOnce(
new Promise<WorkflowRunDetailResponse>((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(
<RunPanel runDetailUrl="/runs/current" tracingListUrl="/runs/current/tracing" />,
)
await screen.findByTestId('monaco-editor')
let rejectEarlierDetail: (error: Error) => void = () => {}
let resolveEarlierTrace: (value: NodeTracingListResponse) => void = () => {}
mockFetchRunDetail.mockReturnValueOnce(
new Promise<WorkflowRunDetailResponse>((_, reject) => {
rejectEarlierDetail = reject
}),
)
mockFetchTracingList
.mockReturnValueOnce(
new Promise<NodeTracingListResponse>((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(
<RunPanel runDetailUrl="/runs/current" tracingListUrl="/runs/current/tracing" />,
)
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()
})
})
+110 -43
View File
@@ -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<RunProps['activeTab']>
type RequestLifetime = {
active: boolean
detailRequest: number
tracingRequest: number
detailSettled: boolean
tracingSettled: boolean
}
const RunPanel: FC<RunProps> = ({
hideResult,
activeTab = 'RESULT',
hideResult,
getResultCallback,
runDetailUrl,
tracingListUrl,
}) => {
const [currentTab, setCurrentTab] = useState<RunTab>(activeTab)
const isListening = useStore((s) => s.isListening)
useEffect(() => {
if (isListening) setCurrentTab('DETAIL')
}, [isListening])
return (
<RunSession
key={JSON.stringify([runDetailUrl, tracingListUrl])}
hideResult={hideResult}
getResultCallback={getResultCallback}
runDetailUrl={runDetailUrl}
tracingListUrl={tracingListUrl}
currentTab={currentTab}
onTabChange={setCurrentTab}
isListening={isListening}
/>
)
}
type RunSessionProps = Omit<RunProps, 'activeTab'> & {
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<string>(activeTab)
const [loading, setLoading] = useState<boolean>(true)
const [loading, setLoading] = useState(true)
const [runDetail, setRunDetail] = useState<WorkflowRunDetailResponse>()
const [list, setList] = useState<NodeTracing[]>([])
const isListening = useStore((s) => s.isListening)
const requestLifetimeRef = useRef<RequestLifetime | null>(null)
const executor = useMemo(() => {
if (runDetail?.created_by_role === 'account') return runDetail.created_by_account?.name || ''
@@ -44,49 +88,72 @@ const RunPanel: FC<RunProps> = ({
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<RunProps> = ({
<Tabs
className="relative flex grow flex-col"
value={currentTab}
onValueChange={(value) => switchTab(value as string)}
onValueChange={(value) => switchTab(value as RunTab)}
>
{/* tab */}
<TabsList className="shrink-0 items-center gap-6 border-b-[0.5px] border-divider-subtle px-4">