Skip to content

Commit

Permalink
Add usePolledAPIFetch hook to do API polling
Browse files Browse the repository at this point in the history
  • Loading branch information
acelaya committed Sep 16, 2024
1 parent e9c2416 commit b023cdb
Show file tree
Hide file tree
Showing 2 changed files with 194 additions and 7 deletions.
102 changes: 96 additions & 6 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 @@ -191,6 +192,100 @@ export function urlPath(strings: TemplateStringsArray, ...params: string[]) {
export function useAPIFetch<T = unknown>(
path: string | null,
params?: QueryParams,
): FetchResult<T> {
// We generate a URL-like key from the path and params, but we could generate
// something simpler, as long as it encodes the same information. The auth
// token is not included in the key, as we assume currently that it does not
// change the result.
const paramStr = recordToQueryString(params ?? {});
return useAPIFetchWithKey(path ? `${path}${paramStr}` : null, path, params);
}

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

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

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

/**
* Hook that fetches data using authenticated API requests, allowing it to be
* retried periodically.
*
* @return The FetchResult with an extra `retries` property that represents the
* amount of times the call was retried after the first one.
*/
export function usePolledAPIFetch<T = unknown>({
path,
params,
shouldRetry: unstableShouldRetry,
retryAfter = 500,
/* istanbul ignore next - test seam */
_setTimeout = setTimeout,
/* istanbul ignore next - test seam */
_clearTimeout = clearTimeout,
}: PolledAPIFetchOptions<T>): FetchResult<T> {
const [fetchKey, setFetchKey] = useState(`${Date.now()}`);
const result = useAPIFetchWithKey<T>(fetchKey, path, params);
const shouldRetry = useStableCallback(unstableShouldRetry);

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, check if request should be retried, and schedule a
// key change to enforce it
if (shouldRetry(result)) {
timeout.current = _setTimeout(() => {
// Date.now() returns a value virtually unique after 1 millisecond has
// passed from previous try, so it will trigger a new fetch.
setFetchKey(`${Date.now()}`);
timeout.current = null;
}, retryAfter);
}

return resetTimeout;
}, [_setTimeout, resetTimeout, result, retryAfter, shouldRetry]);

return result;
}

/**
* Hook that fetches data using authenticated API requests.
*
* @param key - Key identifying the data to be fetched.
* The data will be re-fetched whenever this changes.
* If `null`, nothing will be fetched.
* @param path - Path for API call, or null if there is nothing to fetch
* @param [params] - Query params for API call
*/
function useAPIFetchWithKey<T>(
key: string | null,
path: string | null,
params?: QueryParams,
): FetchResult<T> {
const {
api: { authToken },
Expand All @@ -206,12 +301,7 @@ export function useAPIFetch<T = unknown>(
})
: undefined;

// We generate a URL-like key from the path and params, but we could generate
// something simpler, as long as it encodes the same information. The auth
// token is not included in the key, as we assume currently that it does not
// change the result.
const paramStr = recordToQueryString(params ?? {});
return useFetch(path ? `${path}${paramStr}` : null, fetcher);
return useFetch(key, fetcher);
}

export type PaginatedFetchResult<T> = Omit<FetchResult<T>, 'mutate'> & {
Expand Down
99 changes: 98 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,99 @@ describe('usePaginatedAPIFetch', () => {
assert.equal(getMainContent(wrapper), 'No content');
});
});

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

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

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

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

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({ shouldRetry }) {
const result = usePolledAPIFetch({
path: '/api/some/path',
shouldRetry,

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

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

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

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

assert.notCalled(shouldRetry);
});

it('retries requests until shouldRetry returns false', async () => {
mockFetchFinished();

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

assert.called(shouldRetry);
assert.calledOnce(fakeUseFetch);

// Wait for the timeout callback to be invoked
await delay(1);
// Initial fetch and retry should have happened at this point
assert.calledTwice(fakeUseFetch);
});

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

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

wrapper.unmount();

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

0 comments on commit b023cdb

Please sign in to comment.