From 63a8d6f6eb565f2ea0d98f9d48213f37c0530d10 Mon Sep 17 00:00:00 2001 From: Anshuman Kishore Pandey Date: Tue, 10 Mar 2026 17:32:04 +0100 Subject: [PATCH 1/3] fix: Only enable waitForNewEvent after first empty page to prevent Duration badge delay When viewing an in-progress workflow's history, the Duration badge was hidden for ~50s because waitForNewEvent caused the server to long-poll on every request. Now waitForNewEvent is only enabled after we've already received an empty page, so the badge appears immediately when events finish loading. Signed-off-by: Anshuman Kishore Pandey --- .../workflow-history-fetcher.test.tsx | 114 +++++++++++++++++- .../helpers/workflow-history-fetcher.ts | 13 +- 2 files changed, 120 insertions(+), 7 deletions(-) diff --git a/src/views/workflow-history/helpers/__tests__/workflow-history-fetcher.test.tsx b/src/views/workflow-history/helpers/__tests__/workflow-history-fetcher.test.tsx index ee29d09dc..be82d9b75 100644 --- a/src/views/workflow-history/helpers/__tests__/workflow-history-fetcher.test.tsx +++ b/src/views/workflow-history/helpers/__tests__/workflow-history-fetcher.test.tsx @@ -7,6 +7,7 @@ import { type GetWorkflowHistoryResponse } from '@/route-handlers/get-workflow-h import mswMockEndpoints from '@/test-utils/msw-mock-handlers/helper/msw-mock-endpoints'; import workflowHistoryMultiPageFixture from '../../__fixtures__/workflow-history-multi-page-fixture'; +import { scheduleActivityTaskEvent } from '../../__fixtures__/workflow-history-activity-events'; import WorkflowHistoryFetcher from '../workflow-history-fetcher'; const RETRY_DELAY = 3000; @@ -329,21 +330,119 @@ describe(WorkflowHistoryFetcher.name, () => { jest.useRealTimers(); } }); + + it('should send waitForNewEvent=false before first empty page and true after', async () => { + const emptyPageFixture: GetWorkflowHistoryResponse[] = [ + // Page 1: has events + { + history: { events: [scheduleActivityTaskEvent] }, + rawHistory: [], + archived: false, + nextPageToken: 'page2', + }, + // Page 2: empty events (end of available events) + { + history: { events: [] }, + rawHistory: [], + archived: false, + nextPageToken: 'page3', + }, + // Page 3: empty events (long poll page) + { + history: { events: [] }, + rawHistory: [], + archived: false, + nextPageToken: '', + }, + ]; + + const { fetcher, getCapturedWaitForNewEvent } = setup(queryClient, { + waitForNewEvent: true, + responses: emptyPageFixture, + }); + + fetcher.start(); + + await waitFor(() => { + const state = fetcher.getCurrentState(); + expect(state.hasNextPage).toBe(false); + expect(state.data?.pages).toHaveLength(3); + }); + + const waitForNewEventValues = getCapturedWaitForNewEvent(); + // Page 1: no empty page received yet, so waitForNewEvent=false + expect(waitForNewEventValues[0]).toBe('false'); + // Page 2: still no empty page received yet, so waitForNewEvent=false + expect(waitForNewEventValues[1]).toBe('false'); + // Page 3: after page 2 returned empty, so waitForNewEvent=true + expect(waitForNewEventValues[2]).toBe('true'); + }); + + it('should send waitForNewEvent=false for all requests when param is not set', async () => { + const emptyPageFixture: GetWorkflowHistoryResponse[] = [ + { + history: { events: [scheduleActivityTaskEvent] }, + rawHistory: [], + archived: false, + nextPageToken: 'page2', + }, + { + history: { events: [] }, + rawHistory: [], + archived: false, + nextPageToken: 'page3', + }, + { + history: { events: [] }, + rawHistory: [], + archived: false, + nextPageToken: '', + }, + ]; + + const { fetcher, getCapturedWaitForNewEvent } = setup(queryClient, { + responses: emptyPageFixture, + }); + + fetcher.start(); + + await waitFor(() => { + const state = fetcher.getCurrentState(); + expect(state.hasNextPage).toBe(false); + expect(state.data?.pages).toHaveLength(3); + }); + + const waitForNewEventValues = getCapturedWaitForNewEvent(); + expect(waitForNewEventValues[0]).toBe('false'); + expect(waitForNewEventValues[1]).toBe('false'); + expect(waitForNewEventValues[2]).toBe('false'); + }); }); -function setup(client: QueryClient, options: { failOnPages?: number[] } = {}) { +function setup( + client: QueryClient, + options: { + failOnPages?: number[]; + waitForNewEvent?: boolean; + responses?: GetWorkflowHistoryResponse[]; + } = {} +) { const params = { domain: 'test-domain', cluster: 'test-cluster', workflowId: 'test-workflow-id', runId: 'test-run-id', pageSize: 10, + ...(options.waitForNewEvent !== undefined && { + waitForNewEvent: options.waitForNewEvent, + }), }; - const { getCapturedPageSizes } = mockHistoryEndpoint( - workflowHistoryMultiPageFixture, - options.failOnPages - ); + const { getCapturedPageSizes, getCapturedWaitForNewEvent } = + mockHistoryEndpoint( + options.responses ?? workflowHistoryMultiPageFixture, + options.failOnPages + ); const fetcher = new WorkflowHistoryFetcher(client, params); hoistedFetcher = fetcher; @@ -363,6 +462,7 @@ function setup(client: QueryClient, options: { failOnPages?: number[] } = {}) { params, waitForData, getCapturedPageSizes, + getCapturedWaitForNewEvent, }; } @@ -371,6 +471,7 @@ function mockHistoryEndpoint( failOnPages: number[] = [] ) { const capturedPageSizes: string[] = []; + const capturedWaitForNewEvent: string[] = []; mswMockEndpoints([ { @@ -381,8 +482,10 @@ function mockHistoryEndpoint( const url = new URL(request.url); const nextPage = url.searchParams.get('nextPage'); const pageSize = url.searchParams.get('pageSize'); + const waitForNewEvent = url.searchParams.get('waitForNewEvent'); capturedPageSizes.push(pageSize ?? ''); + capturedWaitForNewEvent.push(waitForNewEvent ?? ''); // Determine current page number based on nextPage param let pageNumber = 1; @@ -413,5 +516,6 @@ function mockHistoryEndpoint( return { getCapturedPageSizes: () => capturedPageSizes, + getCapturedWaitForNewEvent: () => capturedWaitForNewEvent, }; } diff --git a/src/views/workflow-history/helpers/workflow-history-fetcher.ts b/src/views/workflow-history/helpers/workflow-history-fetcher.ts index 4e4f43c4d..075449e7f 100644 --- a/src/views/workflow-history/helpers/workflow-history-fetcher.ts +++ b/src/views/workflow-history/helpers/workflow-history-fetcher.ts @@ -30,6 +30,7 @@ export default class WorkflowHistoryFetcher { (res: WorkflowHistoryQueryResult) => void > | null = null; private shouldContinue: ShouldContinueCallback = () => true; + private hasReceivedEmptyPage: boolean = false; /** * Creates a new WorkflowHistoryFetcher instance. @@ -222,10 +223,18 @@ export default class WorkflowHistoryFetcher { pageSize: pageParam ? WORKFLOW_HISTORY_PAGE_SIZE_CONFIG : WORKFLOW_HISTORY_FIRST_PAGE_SIZE_CONFIG, - waitForNewEvent: params.waitForNewEvent ?? false, + waitForNewEvent: + this.hasReceivedEmptyPage && + (params.waitForNewEvent ?? false), } satisfies WorkflowHistoryQueryParams, }) - ).then((res) => res.json()), + ).then((res) => res.json()) + .then((data: GetWorkflowHistoryResponse) => { + if (data.history?.events?.length === 0) { + this.hasReceivedEmptyPage = true; + } + return data; + }), initialPageParam: undefined, getNextPageParam: (lastPage: GetWorkflowHistoryResponse) => { return lastPage.nextPageToken ? lastPage.nextPageToken : undefined; From 377b651d505ab25ab9e5a2607d3b5accba56dc32 Mon Sep 17 00:00:00 2001 From: Anshuman Kishore Pandey Date: Tue, 10 Mar 2026 17:45:18 +0100 Subject: [PATCH 2/3] fix: Fix lint errors in workflow-history-fetcher changes Fix prettier formatting and import ordering issues. Signed-off-by: Anshuman Kishore Pandey --- .../helpers/__tests__/workflow-history-fetcher.test.tsx | 2 +- .../workflow-history/helpers/workflow-history-fetcher.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/views/workflow-history/helpers/__tests__/workflow-history-fetcher.test.tsx b/src/views/workflow-history/helpers/__tests__/workflow-history-fetcher.test.tsx index be82d9b75..0ad681c18 100644 --- a/src/views/workflow-history/helpers/__tests__/workflow-history-fetcher.test.tsx +++ b/src/views/workflow-history/helpers/__tests__/workflow-history-fetcher.test.tsx @@ -6,8 +6,8 @@ import { waitFor } from '@/test-utils/rtl'; import { type GetWorkflowHistoryResponse } from '@/route-handlers/get-workflow-history/get-workflow-history.types'; import mswMockEndpoints from '@/test-utils/msw-mock-handlers/helper/msw-mock-endpoints'; -import workflowHistoryMultiPageFixture from '../../__fixtures__/workflow-history-multi-page-fixture'; import { scheduleActivityTaskEvent } from '../../__fixtures__/workflow-history-activity-events'; +import workflowHistoryMultiPageFixture from '../../__fixtures__/workflow-history-multi-page-fixture'; import WorkflowHistoryFetcher from '../workflow-history-fetcher'; const RETRY_DELAY = 3000; diff --git a/src/views/workflow-history/helpers/workflow-history-fetcher.ts b/src/views/workflow-history/helpers/workflow-history-fetcher.ts index 075449e7f..2a7919d7b 100644 --- a/src/views/workflow-history/helpers/workflow-history-fetcher.ts +++ b/src/views/workflow-history/helpers/workflow-history-fetcher.ts @@ -224,11 +224,11 @@ export default class WorkflowHistoryFetcher { ? WORKFLOW_HISTORY_PAGE_SIZE_CONFIG : WORKFLOW_HISTORY_FIRST_PAGE_SIZE_CONFIG, waitForNewEvent: - this.hasReceivedEmptyPage && - (params.waitForNewEvent ?? false), + this.hasReceivedEmptyPage && (params.waitForNewEvent ?? false), } satisfies WorkflowHistoryQueryParams, }) - ).then((res) => res.json()) + ) + .then((res) => res.json()) .then((data: GetWorkflowHistoryResponse) => { if (data.history?.events?.length === 0) { this.hasReceivedEmptyPage = true; From 905d6d64b0a181293572b32ae84d533e8887fe9a Mon Sep 17 00:00:00 2001 From: Anshuman Kishore Pandey Date: Fri, 13 Mar 2026 12:58:53 +0100 Subject: [PATCH 3/3] fix: Show duration badges immediately for running workflows instead of suppressing waitForNewEvent Reverts the hasReceivedEmptyPage approach which broke polling (server doesn't return nextPageToken when waitForNewEvent is false). Instead, fixes the duration badge delay at the badge level: running workflows now show an ongoing duration while events are still loading, rather than hiding the badge entirely. Signed-off-by: Anshuman Kishore Pandey --- .../workflow-history-fetcher.test.tsx | 31 +++++++++---------- .../helpers/workflow-history-fetcher.ts | 13 ++------ ...low-history-events-duration-badge.test.tsx | 12 ++++++- ...workflow-history-events-duration-badge.tsx | 4 ++- ...-history-remaining-duration-badge.test.tsx | 8 ++--- ...kflow-history-remaining-duration-badge.tsx | 2 +- 6 files changed, 36 insertions(+), 34 deletions(-) diff --git a/src/views/workflow-history/helpers/__tests__/workflow-history-fetcher.test.tsx b/src/views/workflow-history/helpers/__tests__/workflow-history-fetcher.test.tsx index 0ad681c18..c1020866b 100644 --- a/src/views/workflow-history/helpers/__tests__/workflow-history-fetcher.test.tsx +++ b/src/views/workflow-history/helpers/__tests__/workflow-history-fetcher.test.tsx @@ -331,7 +331,7 @@ describe(WorkflowHistoryFetcher.name, () => { } }); - it('should send waitForNewEvent=false before first empty page and true after', async () => { + it('should send waitForNewEvent=true for all requests when param is set', async () => { const emptyPageFixture: GetWorkflowHistoryResponse[] = [ // Page 1: has events { @@ -340,14 +340,14 @@ describe(WorkflowHistoryFetcher.name, () => { archived: false, nextPageToken: 'page2', }, - // Page 2: empty events (end of available events) + // Page 2: empty events (server keeps nextPageToken for long-polling) { history: { events: [] }, rawHistory: [], archived: false, nextPageToken: 'page3', }, - // Page 3: empty events (long poll page) + // Page 3: empty events (last page) { history: { events: [] }, rawHistory: [], @@ -370,11 +370,9 @@ describe(WorkflowHistoryFetcher.name, () => { }); const waitForNewEventValues = getCapturedWaitForNewEvent(); - // Page 1: no empty page received yet, so waitForNewEvent=false - expect(waitForNewEventValues[0]).toBe('false'); - // Page 2: still no empty page received yet, so waitForNewEvent=false - expect(waitForNewEventValues[1]).toBe('false'); - // Page 3: after page 2 returned empty, so waitForNewEvent=true + // All requests should use waitForNewEvent=true when param is set + expect(waitForNewEventValues[0]).toBe('true'); + expect(waitForNewEventValues[1]).toBe('true'); expect(waitForNewEventValues[2]).toBe('true'); }); @@ -392,12 +390,6 @@ describe(WorkflowHistoryFetcher.name, () => { archived: false, nextPageToken: 'page3', }, - { - history: { events: [] }, - rawHistory: [], - archived: false, - nextPageToken: '', - }, ]; const { fetcher, getCapturedWaitForNewEvent } = setup(queryClient, { @@ -409,13 +401,12 @@ describe(WorkflowHistoryFetcher.name, () => { await waitFor(() => { const state = fetcher.getCurrentState(); expect(state.hasNextPage).toBe(false); - expect(state.data?.pages).toHaveLength(3); }); const waitForNewEventValues = getCapturedWaitForNewEvent(); + // All requests should use waitForNewEvent=false when param is not set expect(waitForNewEventValues[0]).toBe('false'); expect(waitForNewEventValues[1]).toBe('false'); - expect(waitForNewEventValues[2]).toBe('false'); }); }); @@ -509,6 +500,14 @@ function mockHistoryEndpoint( const responseIndex = pageNumber - 1; const response = responses[responseIndex] || responses[responses.length - 1]; + + // Simulate real server behavior: when waitForNewEvent is false + // and the page is empty, the server doesn't return nextPageToken + const isEmpty = response.history?.events?.length === 0; + if (isEmpty && waitForNewEvent === 'false') { + return HttpResponse.json({ ...response, nextPageToken: '' }); + } + return HttpResponse.json(response); }, }, diff --git a/src/views/workflow-history/helpers/workflow-history-fetcher.ts b/src/views/workflow-history/helpers/workflow-history-fetcher.ts index 2a7919d7b..4e4f43c4d 100644 --- a/src/views/workflow-history/helpers/workflow-history-fetcher.ts +++ b/src/views/workflow-history/helpers/workflow-history-fetcher.ts @@ -30,7 +30,6 @@ export default class WorkflowHistoryFetcher { (res: WorkflowHistoryQueryResult) => void > | null = null; private shouldContinue: ShouldContinueCallback = () => true; - private hasReceivedEmptyPage: boolean = false; /** * Creates a new WorkflowHistoryFetcher instance. @@ -223,18 +222,10 @@ export default class WorkflowHistoryFetcher { pageSize: pageParam ? WORKFLOW_HISTORY_PAGE_SIZE_CONFIG : WORKFLOW_HISTORY_FIRST_PAGE_SIZE_CONFIG, - waitForNewEvent: - this.hasReceivedEmptyPage && (params.waitForNewEvent ?? false), + waitForNewEvent: params.waitForNewEvent ?? false, } satisfies WorkflowHistoryQueryParams, }) - ) - .then((res) => res.json()) - .then((data: GetWorkflowHistoryResponse) => { - if (data.history?.events?.length === 0) { - this.hasReceivedEmptyPage = true; - } - return data; - }), + ).then((res) => res.json()), initialPageParam: undefined, getNextPageParam: (lastPage: GetWorkflowHistoryResponse) => { return lastPage.nextPageToken ? lastPage.nextPageToken : undefined; diff --git a/src/views/workflow-history/workflow-history-events-duration-badge/__tests__/workflow-history-events-duration-badge.test.tsx b/src/views/workflow-history/workflow-history-events-duration-badge/__tests__/workflow-history-events-duration-badge.test.tsx index bdaf1c1be..b914f1ad1 100644 --- a/src/views/workflow-history/workflow-history-events-duration-badge/__tests__/workflow-history-events-duration-badge.test.tsx +++ b/src/views/workflow-history/workflow-history-events-duration-badge/__tests__/workflow-history-events-duration-badge.test.tsx @@ -64,11 +64,21 @@ describe('WorkflowHistoryEventsDurationBadge', () => { expect(screen.getByText('Duration: 120')).toBeInTheDocument(); }); - it('does not render badge when loading more events', () => { + it('renders badge when loading more events for running workflow', () => { setup({ loadingMoreEvents: true, }); + expect(screen.getByText(/Duration:/)).toBeInTheDocument(); + }); + + it('does not render badge when loading more events for completed workflow', () => { + setup({ + loadingMoreEvents: true, + workflowCloseStatus: + WorkflowExecutionCloseStatus.WORKFLOW_EXECUTION_CLOSE_STATUS_COMPLETED, + }); + expect(screen.queryByText(/Duration:/)).not.toBeInTheDocument(); }); diff --git a/src/views/workflow-history/workflow-history-events-duration-badge/workflow-history-events-duration-badge.tsx b/src/views/workflow-history/workflow-history-events-duration-badge/workflow-history-events-duration-badge.tsx index 54e94222c..505088b64 100644 --- a/src/views/workflow-history/workflow-history-events-duration-badge/workflow-history-events-duration-badge.tsx +++ b/src/views/workflow-history/workflow-history-events-duration-badge/workflow-history-events-duration-badge.tsx @@ -22,7 +22,9 @@ export default function WorkflowHistoryEventsDurationBadge({ workflowCloseStatus !== 'WORKFLOW_EXECUTION_CLOSE_STATUS_INVALID'; const singleEvent = eventsCount === 1 && !hasMissingEvents; const noDuration = - loadingMoreEvents || singleEvent || (workflowEnded && !endTime); + (loadingMoreEvents && workflowEnded) || + singleEvent || + (workflowEnded && !endTime); const hideDuration = (showOngoingOnly && endTime) || noDuration; const isOngoing = !endTime && !hideDuration; diff --git a/src/views/workflow-history/workflow-history-remaining-duration-badge/__tests__/workflow-history-remaining-duration-badge.test.tsx b/src/views/workflow-history/workflow-history-remaining-duration-badge/__tests__/workflow-history-remaining-duration-badge.test.tsx index 2b52f9160..9d43145f9 100644 --- a/src/views/workflow-history/workflow-history-remaining-duration-badge/__tests__/workflow-history-remaining-duration-badge.test.tsx +++ b/src/views/workflow-history/workflow-history-remaining-duration-badge/__tests__/workflow-history-remaining-duration-badge.test.tsx @@ -36,12 +36,12 @@ describe('WorkflowHistoryRemainingDurationBadge', () => { expect(screen.getByText('Remaining: 5m 30s')).toBeInTheDocument(); }); - it('does not render badge when loading more events', () => { + it('renders badge when loading more events for running workflow', () => { setup({ loadingMoreEvents: true, }); - expect(screen.queryByText(/Remaining:/)).not.toBeInTheDocument(); + expect(screen.getByText('Remaining: 5m 30s')).toBeInTheDocument(); }); it('does not render badge when workflow is archived', () => { @@ -151,11 +151,11 @@ describe('WorkflowHistoryRemainingDurationBadge', () => { startTime={mockStartTime} expectedEndTime={new Date('2024-01-01T10:07:00Z').getTime()} prefix="Remaining:" - workflowIsArchived={false} + workflowIsArchived={true} workflowCloseStatus={ WorkflowExecutionCloseStatus.WORKFLOW_EXECUTION_CLOSE_STATUS_INVALID } - loadingMoreEvents={true} + loadingMoreEvents={false} /> ); diff --git a/src/views/workflow-history/workflow-history-remaining-duration-badge/workflow-history-remaining-duration-badge.tsx b/src/views/workflow-history/workflow-history-remaining-duration-badge/workflow-history-remaining-duration-badge.tsx index 53d266db4..bd7daceff 100644 --- a/src/views/workflow-history/workflow-history-remaining-duration-badge/workflow-history-remaining-duration-badge.tsx +++ b/src/views/workflow-history/workflow-history-remaining-duration-badge/workflow-history-remaining-duration-badge.tsx @@ -18,7 +18,7 @@ export default function WorkflowHistoryRemainingDurationBadge({ workflowIsArchived || workflowCloseStatus !== 'WORKFLOW_EXECUTION_CLOSE_STATUS_INVALID'; - const shouldHide = loadingMoreEvents || workflowEnded; + const shouldHide = workflowEnded; const [remainingDuration, setRemainingDuration] = useState( null