From 36bfe159cbaf826c838ced8f549bb8be47eb9012 Mon Sep 17 00:00:00 2001 From: Martin Marosi Date: Wed, 17 Jul 2024 15:39:06 +0200 Subject: [PATCH] Add experimental useSuspenseLoader hook. --- package-lock.json | 6 +- packages/utils/src/index.ts | 1 + packages/utils/src/useSuspenseLoader/index.ts | 2 + .../useSuspenseLoader.test.tsx | 136 ++++++++++++++++++ .../useSuspenseLoader/useSuspenseLoader.ts | 121 ++++++++++++++++ 5 files changed, 263 insertions(+), 3 deletions(-) create mode 100644 packages/utils/src/useSuspenseLoader/index.ts create mode 100644 packages/utils/src/useSuspenseLoader/useSuspenseLoader.test.tsx create mode 100644 packages/utils/src/useSuspenseLoader/useSuspenseLoader.ts diff --git a/package-lock.json b/package-lock.json index 5c5e6f634..ebdd00146 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42523,7 +42523,7 @@ }, "packages/components": { "name": "@redhat-cloud-services/frontend-components", - "version": "4.2.10", + "version": "4.2.13", "license": "Apache-2.0", "dependencies": { "@patternfly/react-component-groups": "^5.0.0", @@ -43575,7 +43575,7 @@ }, "packages/tsc-transform-imports": { "name": "@redhat-cloud-services/tsc-transform-imports", - "version": "1.0.14", + "version": "1.0.16", "dependencies": { "glob": "10.3.3" }, @@ -43585,7 +43585,7 @@ }, "packages/types": { "name": "@redhat-cloud-services/types", - "version": "1.0.11", + "version": "1.0.12", "license": "Apache-2.0", "devDependencies": { "@patternfly/quickstarts": "^5.0.0", diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 0fe443812..e4017338a 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -20,3 +20,4 @@ export * from './useInsightsNavigate'; export * from './useExportPDF'; export * from './usePromiseQueue'; export * from './useFetchBatched'; +export * from './useSuspenseLoader'; diff --git a/packages/utils/src/useSuspenseLoader/index.ts b/packages/utils/src/useSuspenseLoader/index.ts new file mode 100644 index 000000000..2da49d2ad --- /dev/null +++ b/packages/utils/src/useSuspenseLoader/index.ts @@ -0,0 +1,2 @@ +export { default as suspenseLoader } from './useSuspenseLoader'; +export * from './useSuspenseLoader'; diff --git a/packages/utils/src/useSuspenseLoader/useSuspenseLoader.test.tsx b/packages/utils/src/useSuspenseLoader/useSuspenseLoader.test.tsx new file mode 100644 index 000000000..8fa430015 --- /dev/null +++ b/packages/utils/src/useSuspenseLoader/useSuspenseLoader.test.tsx @@ -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['loader'] }) => { + const data = loader(funcArgs); + return
{JSON.stringify(data)}
; +}; + +const CacheComponent = ({ + funcArgs, + loaderFunc, + afterResolve, + afterReject, +}: { + afterResolve?: (result: any) => void; + afterReject?: (error: unknown) => void; + loaderFunc: (...args: unknown[]) => Promise; + funcArgs?: unknown[]; +}) => { + const { loader, purgeCache } = useSuspenseLoader(loaderFunc, afterResolve, afterReject); + return ( + + + + + + + ); +}; + +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(); + + 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(); + 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(); + + 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(); + expect(mockCall).toHaveBeenLastCalledWith([props2]); + expect(await screen.findByText(JSON.stringify(props2))).toBeInTheDocument(); + + currentProps.curr = props3; + rerender(); + 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(); + 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(); + + 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(); + + 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(); + }); + + 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(); + }); +}); diff --git a/packages/utils/src/useSuspenseLoader/useSuspenseLoader.ts b/packages/utils/src/useSuspenseLoader/useSuspenseLoader.ts new file mode 100644 index 000000000..42dc85601 --- /dev/null +++ b/packages/utils/src/useSuspenseLoader/useSuspenseLoader.ts @@ -0,0 +1,121 @@ +import { useRef, useState } from 'react'; +type LoaderCache = { + resolved: boolean; + rejected: boolean; + error?: unknown; + promise?: Promise; + 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}) => { + * const data = loader('id'); + * return
{JSON.stringify(data)}
; + * } + * const CacheLayer = () => { + * const { loader, purgeCache } = useSuspenseLoader(fetchData); + * + * return ( + * + * + * + * + * + * + * ) + * } + * + */ +function useSuspenseLoader>( + asyncMethod: (...args: T) => Promise, + afterResolve?: (result: R) => void, + afterReject?: (error: unknown) => void +) { + const storage = useRef(new Map>()); + const [, setRender] = useState(0); + function forceRender() { + setRender((p) => p + 1); + } + return { + loader: (...args: Parameters) => { + 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 Promise> = (...arhs: Parameters) => Awaited>; + +export default useSuspenseLoader;