Skip to content

Conversation

@Assem-Uber
Copy link
Contributor

@Assem-Uber Assem-Uber commented Nov 3, 2025

Summary
Create a utility that allows start/stop loading history pages. The utility is not react APIs dependant so that it can be started and closed in a separate flow other than the render cycle. This allows fetching more than single page without having to render once for each fetch.

Changes

  • Created WorkflowHistoryFetcher
  • Use InfiniteQueryObserver to keep similar API for data & status.
  • Provide the state changes for subscribers of onChange
  • Create history fixture
  • Create testing file

Background about InfiniteQueryObserver
Understanding InfiniteQueryObserver behaviour is key for understanding this utility. The InfiniteQueryObserver works in a similar way as useInfiniteQuery, In fact it is used internally within the hook.

The InfiniteQueryObserver starts fetching the first page once you subscribe to changes. In order to control this, the observer is started with a disabled state and we enable it when the user request starting the fetcher or request loading a single page. Afterwards loading next pages are done with a dedicated function fetchNextPage.

The Observer provides an api to listen for state updates which we depend on in order to start fetching next page once the current one is fetched. Also the Observer provides an API for retries that handles retry counts and delays for us and only consider the request failed after exhausting the retries.

Testing
The utility was tested integrated and tested with the history page. Recording available in #1064

Signed-off-by: Assem Hafez <assem.hafez@uber.com>
@Assem-Uber Assem-Uber changed the title Create fetcher utility feat: Create fetcher utility Nov 3, 2025
Assem-Uber and others added 2 commits November 3, 2025 10:26
const shouldEnableQuery =
(!fetchedFirstPage && shouldContinue(currentState)) || fetchedFirstPage;

if (shouldEnableQuery) {
Copy link
Member

Choose a reason for hiding this comment

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

Should the function return early if shouldEnableQuery === false?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good question, the observer logic below is still needed to continue fetching next pages once the first one is done. So the steps are:

  • Enable the query to fetch the first page
  • listen to query updates to fetch the next page


// Auto stop when there are no more pages (end of history) or when there is a fresh error happens after the start.
// isError is true when the request failes and retries are exhausted.
if (res.hasNextPage === false || (res.isError && emitCount > 1)) {
Copy link
Member

Choose a reason for hiding this comment

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

I believe the intent is if emitCount > MAX_RETRIES?

Is that the meaning of 1?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Retries are handled in the configuration. This condition is responsible about this part when there is a fresh error happens after the start. Basically if you are just starting the fetcher and there was an existing Error from previous start, you don't want to stop. This means you only stop when emitCount>1 (not fresh started). I updated the comment to make it more clear.

Comment on lines 84 to 85
// only start emit (fetching next pages) after the initial fetch is complete
// first page is already fetched on the first subscription below
Copy link
Member

Choose a reason for hiding this comment

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

?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I updated the comment above let me know if it still unclear

Comment on lines 108 to 118
fetchSingleNextPage(): void {
const state = this.getCurrentState();

if (state.status === 'pending') {
this.observer.setOptions({
...this.buildObserverOptions(this.params),
enabled: true,
});
} else if (!state.isFetchingNextPage && state.hasNextPage)
state.fetchNextPage();
}
Copy link
Member

Choose a reason for hiding this comment

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

I'm not sure I follow.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The fetchSingleNextPage gives more control for fetching by allowing users to request fetching a single page. It handles this requriment for two cases:
1- query is not enabled yet: in this case it enables the query and the first page is fetched by default.
2- query is already enabled: in this case it would manually request the second page.

Added a comment for explaination

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.

I think understanding InfiniteQueryObserver behavior is key for understanding this utility. I'm going to add a section in the PR description to cover that. Please let me know if it helps.

expect(callback1.mock.calls.length).toBeGreaterThan(1);
});

const countBeforeUnsubscribe = callback1.mock.calls.length;
Copy link
Member

Choose a reason for hiding this comment

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

Should countBeforeUnsubscribe be hardcoded rather than derived?

Can the value change between test runs?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It can be argued which is better here. The intent is not to test how much the Observer calls onChange as this depends on multiple implementation details within the class and how they batch the updates. While the test case is making sure whatever the count the observer calls onChange the second callback should recieve more updates.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

For more context, the count before is 3 and after is 5

const countBeforeUnsubscribe = callback1.mock.calls.length;
unsubscribe1();

fetcher.fetchSingleNextPage();
Copy link
Member

Choose a reason for hiding this comment

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

I'm not sure I follow this test and what unsubscribe has to do with this line.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The test case is starting two listners at the same time. It fetch the first page and unsubsribe one of the listeners, then it fetch another page and make sure that the first subscriber didn't recieve any new updates


await waitFor(() => {
const state = fetcher.getCurrentState();
expect(state.isFetching).toBe(false);
Copy link
Member

Choose a reason for hiding this comment

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

It isn't clear how shouldContinue => false affects behavior.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Basically in this case it wont start fetching as the condition is always false. I changed the test case text to make it more specific.

Comment on lines 147 to 148
// Fast-forward through retry delays (3 retries * 3000ms each)
await jest.advanceTimersByTimeAsync(3 * 3000);
Copy link
Member

Choose a reason for hiding this comment

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

Extracting constants would be appropriate and update all call sites to use the constants instead of hardcoded values.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I guess by all call sites, you mean the test case values. There is one value, so i can extract as a const for readability.
But also maybe you mean share the constant between test and code, if so, this is what i was avoiding on purpose.

Assem-Uber and others added 3 commits November 5, 2025 13:57
Signed-off-by: Assem Hafez <assem.hafez@uber.com>
Signed-off-by: Assem Hafez <assem.hafez@uber.com>
@Assem-Uber Assem-Uber merged commit bcaca52 into cadence-workflow:master Nov 7, 2025
5 checks passed
@Assem-Uber Assem-Uber deleted the feature/15952/create-fetcher-utility branch November 7, 2025 14:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants