Skip to content

Commit

Permalink
Merge pull request #2036 from Hyperkid123/suspense-loader
Browse files Browse the repository at this point in the history
Add experimental useSuspenseLoader hook.
  • Loading branch information
Hyperkid123 authored Jul 19, 2024
2 parents 63bc3ca + 36bfe15 commit 805cb4a
Show file tree
Hide file tree
Showing 5 changed files with 263 additions and 3 deletions.
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ export * from './useInsightsNavigate';
export * from './useExportPDF';
export * from './usePromiseQueue';
export * from './useFetchBatched';
export * from './useSuspenseLoader';
2 changes: 2 additions & 0 deletions packages/utils/src/useSuspenseLoader/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default as suspenseLoader } from './useSuspenseLoader';
export * from './useSuspenseLoader';
136 changes: 136 additions & 0 deletions packages/utils/src/useSuspenseLoader/useSuspenseLoader.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import React, { Suspense } from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import ErrorBoundaryPF from '@patternfly/react-component-groups/dist/dynamic/ErrorBoundary';
import useSuspenseLoader from './useSuspenseLoader';
import { act } from 'react-dom/test-utils';

const DummyComponent = ({ loader, funcArgs }: { funcArgs?: unknown[]; loader: ReturnType<typeof useSuspenseLoader>['loader'] }) => {
const data = loader(funcArgs);
return <div>{JSON.stringify(data)}</div>;
};

const CacheComponent = ({
funcArgs,
loaderFunc,
afterResolve,
afterReject,
}: {
afterResolve?: (result: any) => void;
afterReject?: (error: unknown) => void;
loaderFunc: (...args: unknown[]) => Promise<unknown>;
funcArgs?: unknown[];
}) => {
const { loader, purgeCache } = useSuspenseLoader(loaderFunc, afterResolve, afterReject);
return (
<ErrorBoundaryPF headerTitle="Expected error">
<Suspense fallback="Loading">
<button onClick={purgeCache}>Purge Cache</button>
<DummyComponent loader={loader} funcArgs={funcArgs} />
</Suspense>
</ErrorBoundaryPF>
);
};

describe('useSuspenseLoader', () => {
test('should call fetch data only once', async () => {
const resp = 'data';
const mockCall = jest.fn().mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve(resp), 100)));

const { rerender } = render(<CacheComponent loaderFunc={mockCall} />);

expect(mockCall).toHaveBeenCalledTimes(1);
expect(screen.getByText('Loading')).toBeInTheDocument();
expect(await screen.findByText(JSON.stringify(resp))).toBeInTheDocument();
// no additional async calls should happen on re-render with cached data
rerender(<CacheComponent loaderFunc={mockCall} />);
expect(await screen.findByText(JSON.stringify(resp))).toBeInTheDocument();
expect(mockCall).toHaveBeenCalledTimes(1);
});

test('should call fetch data per cache key change', async () => {
const props1 = 'props1';
const props2 = 'props2';
const props3 = 'props3';

const currentProps = { curr: props1 };
const mockCall = jest.fn().mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(() => resolve(currentProps.curr), 100);
})
);

const { rerender } = render(<CacheComponent loaderFunc={mockCall} funcArgs={[props1]} />);

expect(mockCall).toHaveBeenCalledTimes(1);
expect(mockCall).toHaveBeenCalledWith([props1]);
expect(screen.getByText('Loading')).toBeInTheDocument();
expect(await screen.findByText(JSON.stringify(props1))).toBeInTheDocument();

currentProps.curr = props2;
rerender(<CacheComponent loaderFunc={mockCall} funcArgs={[props2]} />);
expect(mockCall).toHaveBeenLastCalledWith([props2]);
expect(await screen.findByText(JSON.stringify(props2))).toBeInTheDocument();

currentProps.curr = props3;
rerender(<CacheComponent loaderFunc={mockCall} funcArgs={[props3]} />);
expect(mockCall).toHaveBeenLastCalledWith([props3]);
expect(await screen.findByText(JSON.stringify(props3))).toBeInTheDocument();

// again again for props1 but it should not trigger addtional call because the request is already in cache
currentProps.curr = props1;
rerender(<CacheComponent loaderFunc={mockCall} funcArgs={[props1]} />);
expect(await screen.findByText(JSON.stringify(props1))).toBeInTheDocument();

expect(mockCall).toHaveBeenCalledTimes(3);
});

test('should re-fetch data if cache is cleared', async () => {
const currentResp = { curr: 'data' };
const mockCall = jest.fn().mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve(currentResp.curr), 100)));

render(<CacheComponent loaderFunc={mockCall} />);

expect(mockCall).toHaveBeenCalledTimes(1);
expect(screen.getByText('Loading')).toBeInTheDocument();
expect(await screen.findByText(JSON.stringify('data'))).toBeInTheDocument();
// restart fetching after cache is cleared
currentResp.curr = 'data-refreshed';
act(() => {
screen.getByText('Purge Cache').click();
});
expect(await screen.findByText(JSON.stringify('data-refreshed'))).toBeInTheDocument();
expect(mockCall).toHaveBeenCalledTimes(2);
});

test('should call afterResolve argument on sucesfull fetch', async () => {
const resp = 'data';
const afterResolve = jest.fn();
const mockCall = jest.fn().mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve(resp), 100)));

render(<CacheComponent loaderFunc={mockCall} afterResolve={afterResolve} />);

expect(await screen.findByText(JSON.stringify(resp))).toBeInTheDocument();
expect(mockCall).toHaveBeenCalledTimes(1);
expect(afterResolve).toHaveBeenCalledTimes(1);
expect(afterResolve).toHaveBeenCalledWith(resp);
});

test('should call afterReject argument on failed fetch', async () => {
const error = 'error';
const afterReject = jest.fn();
const mockCall = jest.fn().mockRejectedValue(error);

await waitFor(async () => {
await render(<CacheComponent loaderFunc={mockCall} afterReject={afterReject} />);
});

expect(screen.queryByText(JSON.stringify(error))).not.toBeInTheDocument();

expect(mockCall).toHaveBeenCalledTimes(1);
expect(afterReject).toHaveBeenCalledTimes(1);
expect(afterReject).toHaveBeenCalledWith(error);
// error should bubble to error boundary
expect(await screen.findByText('Expected error')).toBeInTheDocument();
});
});
121 changes: 121 additions & 0 deletions packages/utils/src/useSuspenseLoader/useSuspenseLoader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { useRef, useState } from 'react';
type LoaderCache<R> = {
resolved: boolean;
rejected: boolean;
error?: unknown;
promise?: Promise<R>;
result?: R;
};
// This cache is super simple, might be problematic in some cases
function getCacheKey(...args: unknown[]): string {
try {
return JSON.stringify(args);
} catch (error) {
return 'undefined';
}
}
const baseCacheValue = {
resolved: false,
rejected: false,
error: undefined,
promise: undefined,
result: undefined,
};
/**
* This is an experimental hook that allows you to use React suspense with a async loader function.
* Be advised that not all use cases are covered and this is a very simple implementation.
* The implementation is being tested in Chrome and Learning resources.
*
* @example
*
* const fetchData = async (id: string) => {...}
*
* const DisplayComponent = ({ loader }: {loader: UnwrappedLoader<fetchData>}) => {
* const data = loader('id');
* return <div>{JSON.stringify(data)}</div>;
* }
* const CacheLayer = () => {
* const { loader, purgeCache } = useSuspenseLoader(fetchData);
*
* return (
* <Suspense fallback="Loading">
* <button onClick={purgeCache}>Purge Cache</button>
* <ErrorBoundaryPF headerTitle="Expected error">
* <DisplayComponent loader={loader} />
* </ErrorBoundaryPF>
* </Suspense>
* )
* }
*
*/
function useSuspenseLoader<R, T extends Array<unknown>>(
asyncMethod: (...args: T) => Promise<R>,
afterResolve?: (result: R) => void,
afterReject?: (error: unknown) => void
) {
const storage = useRef(new Map<string, LoaderCache<R>>());
const [, setRender] = useState(0);
function forceRender() {
setRender((p) => p + 1);
}
return {
loader: (...args: Parameters<typeof asyncMethod>) => {
const cacheKey = getCacheKey(...args);
let loaderCache = storage.current.get(cacheKey);

if (loaderCache?.rejected) {
throw loaderCache.error;
}
// desult will always have a type here
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (loaderCache?.resolved) return loaderCache.result!;
if (loaderCache?.promise) {
throw loaderCache.promise;
}
if (!storage.current.get(cacheKey)) {
// this has to be copied because of the reference
// if not copied, subsequent calls will override previous cache values
storage.current.set(cacheKey, { ...baseCacheValue });
}
// cache will always exist here because it was created in the previous step
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
loaderCache = storage.current.get(cacheKey)!;
loaderCache.promise = asyncMethod(...args)
.then((res) => {
const loaderCache = storage.current.get(cacheKey);
if (!loaderCache) {
throw 'No loader cache';
}
loaderCache.promise = undefined;
loaderCache.resolved = true;
loaderCache.result = res;
afterResolve?.(res);
forceRender();
return res;
})
.catch((error) => {
const loaderCache = storage.current.get(cacheKey);
if (!loaderCache) {
throw 'No loader cache';
}
loaderCache.promise = undefined;
loaderCache.rejected = true;
loaderCache.error = error;
afterReject?.(error);
forceRender();
return error;
});
throw loaderCache.promise;
},
purgeCache: () => {
// to restart the fetching after a mutation is done,
// it has to be triggered manually
storage.current.clear();
forceRender();
},
};
}

export type UnwrappedLoader<F extends (...args: any[]) => Promise<unknown>> = (...arhs: Parameters<F>) => Awaited<ReturnType<F>>;

export default useSuspenseLoader;

0 comments on commit 805cb4a

Please sign in to comment.