-
Notifications
You must be signed in to change notification settings - Fork 102
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #2036 from Hyperkid123/suspense-loader
Add experimental useSuspenseLoader hook.
- Loading branch information
Showing
5 changed files
with
263 additions
and
3 deletions.
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
136
packages/utils/src/useSuspenseLoader/useSuspenseLoader.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
121
packages/utils/src/useSuspenseLoader/useSuspenseLoader.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |