Skip to content

Commit

Permalink
Add usePolledAPIFetch hook to do API polling (#6683)
Browse files Browse the repository at this point in the history
  • Loading branch information
acelaya authored Sep 18, 2024
1 parent 5aa855c commit c2d6668
Show file tree
Hide file tree
Showing 2 changed files with 171 additions and 1 deletion.
71 changes: 71 additions & 0 deletions lms/static/scripts/frontend_apps/utils/api.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useStableCallback } from '@hypothesis/frontend-shared';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';

import type { Pagination } from '../api-types';
Expand Down Expand Up @@ -214,6 +215,76 @@ export function useAPIFetch<T = unknown>(
return useFetch(path ? `${path}${paramStr}` : null, fetcher);
}

export type PolledAPIFetchOptions<T> = {
/** Path for API call */
path: string;
/** Query params for API call */
params?: QueryParams;
/** Determines if, based on the result, the API should be called again */
shouldRefresh: (result: FetchResult<T>) => boolean;

/**
* Amount of ms after which a refresh should happen, if shouldRefresh()
* returns true.
* Defaults to 500ms.
*/
refreshAfter?: number;

/** Test seam */
_setTimeout?: typeof setTimeout;
/** Test seam */
_clearTimeout?: typeof clearTimeout;
};

/**
* Hook that fetches data using authenticated API requests.
*
* This is a variant of {@link useAPIFetch} that supports automatically
* refreshing results at an interval until some condition is met.
* This is useful for example when calling an API that reports the status of a
* long-running operation.
*/
export function usePolledAPIFetch<T = unknown>({
path,
params,
shouldRefresh: unstableShouldRefresh,
refreshAfter = 500,
/* istanbul ignore next - test seam */
_setTimeout = setTimeout,
/* istanbul ignore next - test seam */
_clearTimeout = clearTimeout,
}: PolledAPIFetchOptions<T>): FetchResult<T> {
const result = useAPIFetch<T>(path, params);
const shouldRefresh = useStableCallback(unstableShouldRefresh);

const timeout = useRef<ReturnType<typeof _setTimeout> | null>(null);
const resetTimeout = useCallback(() => {
if (timeout.current) {
_clearTimeout(timeout.current);
}
timeout.current = null;
}, [_clearTimeout]);

useEffect(() => {
if (result.isLoading) {
return () => {};
}

// Once we finish loading, schedule a retry if the request should be
// refreshed
if (shouldRefresh(result)) {
timeout.current = _setTimeout(() => {
result.retry();
timeout.current = null;
}, refreshAfter);
}

return resetTimeout;
}, [_setTimeout, refreshAfter, resetTimeout, result, shouldRefresh]);

return result;
}

export type PaginatedFetchResult<T> = Omit<FetchResult<T>, 'mutate'> & {
/** Determines if currently loading the first page */
isLoadingFirstPage: boolean;
Expand Down
101 changes: 100 additions & 1 deletion lms/static/scripts/frontend_apps/utils/test/api-test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { waitFor } from '@hypothesis/frontend-testing';
import { delay, waitFor } from '@hypothesis/frontend-testing';
import { mount } from 'enzyme';

import { Config } from '../../config';
Expand All @@ -9,6 +9,7 @@ import {
useAPIFetch,
$imports,
usePaginatedAPIFetch,
usePolledAPIFetch,
} from '../api';

function createResponse(status, body) {
Expand Down Expand Up @@ -651,3 +652,101 @@ describe('usePaginatedAPIFetch', () => {
assert.equal(getMainContent(wrapper), 'No content');
});
});

describe('usePolledAPIFetch', () => {
let fakeUseFetch;
let fakeRetry;
let fakeClearTimeout;

function mockFetchFinished() {
fakeUseFetch.returns({
data: {},
isLoading: false,
retry: fakeRetry,
});
}

function mockLoadingState() {
fakeUseFetch.returns({
data: null,
isLoading: true,
});
}

beforeEach(() => {
fakeUseFetch = sinon.stub();
mockLoadingState();

fakeRetry = sinon.stub();
fakeClearTimeout = sinon.stub();

const fakeUseConfig = sinon.stub();
fakeUseConfig.returns({
api: { authToken: 'some-token' },
});

$imports.$mock({
'../config': { useConfig: fakeUseConfig },
'./fetch': { useFetch: fakeUseFetch },
});
});

afterEach(() => {
$imports.$restore();
});

function TestWidget({ shouldRefresh }) {
const result = usePolledAPIFetch({
path: '/api/some/path',
shouldRefresh,

// Keep asynchronous nature of mocked setTimeout, but with a virtually
// immediate execution of the callback
_setTimeout: callback => setTimeout(callback),
_clearTimeout: fakeClearTimeout,
});

return (
<div data-testid="main-content">
{result.isLoading && 'Loading'}
{result.data && 'Loaded'}
</div>
);
}

function createComponent(shouldRefresh) {
return mount(<TestWidget shouldRefresh={shouldRefresh} />);
}

it('does not refresh while loading is in progress', async () => {
const shouldRefresh = sinon.stub().returns(true);
createComponent(shouldRefresh);

assert.notCalled(shouldRefresh);
});

it('refreshes requests until shouldRefresh returns false', async () => {
mockFetchFinished();

const shouldRefresh = sinon.stub().returns(true);
createComponent(shouldRefresh);

assert.called(shouldRefresh);

// Retry should be called once timeout ends
assert.notCalled(fakeRetry);
await delay(1);
assert.calledOnce(fakeRetry);
});

it('clears pending timeout when component is unmounted', () => {
mockFetchFinished();

const shouldRefresh = sinon.stub().returns(true);
const wrapper = createComponent(shouldRefresh);

wrapper.unmount();

assert.called(fakeClearTimeout);
});
});

0 comments on commit c2d6668

Please sign in to comment.