Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { QueryClient } from '@tanstack/react-query';

import { act, renderHook, waitFor } from '@/test-utils/rtl';

import workflowHistoryMultiPageFixture from '../../__fixtures__/workflow-history-multi-page-fixture';
import { workflowPageUrlParams } from '../../__fixtures__/workflow-page-url-params';
import WorkflowHistoryFetcher from '../../helpers/workflow-history-fetcher';
import useWorkflowHistoryFetcher from '../use-workflow-history-fetcher';

jest.mock('../../helpers/workflow-history-fetcher');

const mockParams = {
...workflowPageUrlParams,
pageSize: 50,
waitForNewEvent: true,
};
let mockFetcherInstance: jest.Mocked<WorkflowHistoryFetcher>;
let mockOnChangeCallback: jest.Mock;
let mockUnsubscribe: jest.Mock;

function setup() {
const hookResult = renderHook(() => useWorkflowHistoryFetcher(mockParams));

return {
...hookResult,
mockFetcherInstance,
mockOnChangeCallback,
mockUnsubscribe,
};
}

describe(useWorkflowHistoryFetcher.name, () => {
beforeEach(() => {
jest.clearAllMocks();

mockOnChangeCallback = jest.fn();
mockUnsubscribe = jest.fn();

mockFetcherInstance = {
start: jest.fn(),
stop: jest.fn(),
destroy: jest.fn(),
fetchSingleNextPage: jest.fn(),
onChange: jest.fn((callback) => {
mockOnChangeCallback.mockImplementation(callback);
return mockUnsubscribe;
}),
getCurrentState: jest.fn(() => ({
data: undefined,
error: null,
isError: false,
isLoading: false,
isPending: true,
isFetchingNextPage: false,
hasNextPage: false,
status: 'pending' as const,
})),
} as unknown as jest.Mocked<WorkflowHistoryFetcher>;

(
WorkflowHistoryFetcher as jest.MockedClass<typeof WorkflowHistoryFetcher>
).mockImplementation(() => mockFetcherInstance);
});

afterEach(() => {
jest.clearAllMocks();
});

it('should create a WorkflowHistoryFetcher instance with correct params', () => {
setup();

expect(WorkflowHistoryFetcher).toHaveBeenCalledWith(
expect.any(QueryClient),
mockParams
);
expect(WorkflowHistoryFetcher).toHaveBeenCalledTimes(1);
});

it('should reuse the same fetcher instance on re-renders', () => {
const { rerender } = setup();

rerender();
rerender();

expect(WorkflowHistoryFetcher).toHaveBeenCalledTimes(1);
});

it('should subscribe to fetcher state changes on mount', () => {
setup();

expect(mockFetcherInstance.onChange).toHaveBeenCalledTimes(1);
});

it('should start fetcher to load first page on mount', () => {
setup();

expect(mockFetcherInstance.start).toHaveBeenCalledWith(
expect.any(Function)
);
expect(mockFetcherInstance.start).toHaveBeenCalledTimes(1);
});

it('should return initial history query state', () => {
const { result } = setup();

expect(result.current.historyQuery).toBeDefined();
expect(result.current.historyQuery.isPending).toBe(true);
});

it('should update historyQuery when fetcher state changes', async () => {
const { result, mockOnChangeCallback } = setup();

const newState = {
data: {
pages: [workflowHistoryMultiPageFixture[0]],
pageParams: [],
},
error: null,
isError: false,
isLoading: false,
isPending: false,
isFetchingNextPage: false,
hasNextPage: true,
status: 'success' as const,
};

act(() => {
mockOnChangeCallback(newState);
});

await waitFor(() => {
expect(result.current.historyQuery.status).toBe('success');
});
});

it('should call fetcher.start() with custom shouldContinue callback passed to startLoadingHistory', () => {
const { result, mockFetcherInstance } = setup();
const customShouldContinue = jest.fn(() => false);

act(() => {
result.current.startLoadingHistory(customShouldContinue);
});

expect(mockFetcherInstance.start).toHaveBeenCalledWith(
customShouldContinue
);
});

it('should call fetcher.stop() within stopLoadingHistory', () => {
const { result, mockFetcherInstance } = setup();

act(() => {
result.current.stopLoadingHistory();
});

expect(mockFetcherInstance.stop).toHaveBeenCalledTimes(1);
});

it('should call fetcher.fetchSingleNextPage() within fetchSingleNextPage', () => {
const { result, mockFetcherInstance } = setup();

act(() => {
result.current.fetchSingleNextPage();
});

expect(mockFetcherInstance.fetchSingleNextPage).toHaveBeenCalledTimes(1);
});

it('should unsubscribe from onChange when unmounted', () => {
const { unmount, mockUnsubscribe } = setup();

unmount();

expect(mockUnsubscribe).toHaveBeenCalledTimes(1);
});

it('should call fetcher.unmount() when component unmounts', () => {
const { unmount, mockFetcherInstance } = setup();

unmount();

expect(mockFetcherInstance.destroy).toHaveBeenCalledTimes(1);
});

it('should return all expected methods and state', () => {
const { result } = setup();

expect(result.current).toHaveProperty('historyQuery');
expect(result.current).toHaveProperty('startLoadingHistory');
expect(result.current).toHaveProperty('stopLoadingHistory');
expect(result.current).toHaveProperty('fetchSingleNextPage');
expect(typeof result.current.startLoadingHistory).toBe('function');
expect(typeof result.current.stopLoadingHistory).toBe('function');
expect(typeof result.current.fetchSingleNextPage).toBe('function');
});
});
89 changes: 89 additions & 0 deletions src/views/workflow-history/hooks/use-workflow-history-fetcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { useCallback, useEffect, useRef } from 'react';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this file included in this PR because it's stacked on the prior PR?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, unfortunately both exists in this PR as they are dependant.


import {
type InfiniteData,
type InfiniteQueryObserverResult,
useQueryClient,
} from '@tanstack/react-query';

import useThrottledState from '@/hooks/use-throttled-state';
import {
type WorkflowHistoryQueryParams,
type GetWorkflowHistoryResponse,
type RouteParams,
} from '@/route-handlers/get-workflow-history/get-workflow-history.types';
import { type RequestError } from '@/utils/request/request-error';

import WorkflowHistoryFetcher from '../helpers/workflow-history-fetcher';
import { type ShouldContinueCallback } from '../helpers/workflow-history-fetcher.types';

export default function useWorkflowHistoryFetcher(
params: WorkflowHistoryQueryParams & RouteParams,
throttleMs: number = 2000
) {
const queryClient = useQueryClient();
const fetcherRef = useRef<WorkflowHistoryFetcher | null>(null);

if (!fetcherRef.current) {
fetcherRef.current = new WorkflowHistoryFetcher(queryClient, params);
}

const [historyQuery, setHistoryQuery] = useThrottledState<
InfiniteQueryObserverResult<
InfiniteData<GetWorkflowHistoryResponse>,
RequestError
>
>(fetcherRef.current.getCurrentState(), throttleMs, {
leading: true,
trailing: true,
});

useEffect(() => {
if (!fetcherRef.current) return;

const unsubscribe = fetcherRef.current.onChange((state) => {
const pagesCount = state.data?.pages?.length || 0;
// immediately set if there is the first page without throttling other wise throttle
const executeImmediately = pagesCount <= 1;
setHistoryQuery(() => state, executeImmediately);
});

// Fetch first page
fetcherRef.current.start((state) => !state?.data?.pages?.length);
Comment on lines +51 to +52
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder why we fetch the first page explicitly rather than relying on the flywheel / fetch-loop?

Copy link
Contributor Author

@Assem-Uber Assem-Uber Nov 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both works, in this case i went for being more explicit about loading the first page separately from the conditions for keep loading more. In any case we won't want to load and empty page, so it is some how safer to assume first page always loads. The other approach works too, i don't see issues with it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Btw fetchSingleNextPage can also be used here, but i went with that assuming this is more readable. Let me know if it is not the same for you.


return () => {
unsubscribe();
};
}, [setHistoryQuery]);

useEffect(() => {
return () => {
fetcherRef.current?.destroy();
};
}, []);

const startLoadingHistory = useCallback(
(shouldContinue: ShouldContinueCallback = () => true) => {
if (!fetcherRef.current) return;
fetcherRef.current.start(shouldContinue);
},
[]
);

const stopLoadingHistory = useCallback(() => {
if (!fetcherRef.current) return;
fetcherRef.current.stop();
}, []);

const fetchSingleNextPage = useCallback(() => {
if (!fetcherRef.current) return;
fetcherRef.current.fetchSingleNextPage();
}, []);

return {
historyQuery,
startLoadingHistory,
stopLoadingHistory,
fetchSingleNextPage,
};
}