Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add experimental useSuspenseLoader hook. #2036

Merged
merged 1 commit into from
Jul 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
Loading