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 17, 2024
1 parent e9c2416 commit 01bb9f7
Show file tree
Hide file tree
Showing 2 changed files with 191 additions and 7 deletions.
99 changes: 93 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,97 @@ 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 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, allowing it to be
* refreshed periodically.
*/
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 [fetchKey, setFetchKey] = useState(`${Date.now()}`);
const result = useAPIFetchWithKey<T>(fetchKey, 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, check if request should be refreshed, and
// schedule a key change to enforce it
if (shouldRefresh(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;
}, refreshAfter);
}

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

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 +298,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({ 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, 1),
_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('retries requests until shouldRefresh returns false', async () => {
mockFetchFinished();

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

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

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

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 01bb9f7

Please sign in to comment.