diff --git a/packages/use-dataloader/README.md b/packages/use-dataloader/README.md index fd126030d..10b50758c 100644 --- a/packages/use-dataloader/README.md +++ b/packages/use-dataloader/README.md @@ -77,10 +77,10 @@ const failingPromise = async () => { } const App = () => { - useDataLoader('local-error', failingPromise, { - onError: (error) => { + useDataLoader('local-error', failingPromise, { + onError: error => { console.log(`local onError: ${error}`) - } + }, }) useDataLoader('error', failingPromise) @@ -88,7 +88,7 @@ const App = () => { return null } -const globalOnError = (error) => { +const globalOnError = error => { console.log(`global onError: ${error}`) } @@ -219,6 +219,7 @@ const useDataLoader = ( pollingInterval, // Relaunch the request after the last success enabled = true, // Launch request automatically keepPreviousData = true, // Do we need to keep the previous data after reload + maxDataLifetime, // Max time before previous success data is outdated (in millisecond) } = {}, ) ``` diff --git a/packages/use-dataloader/src/DataLoaderProvider.tsx b/packages/use-dataloader/src/DataLoaderProvider.tsx index c1425511f..526838c09 100644 --- a/packages/use-dataloader/src/DataLoaderProvider.tsx +++ b/packages/use-dataloader/src/DataLoaderProvider.tsx @@ -24,6 +24,10 @@ type UseDataLoaderInitializerArgs = { method: () => PromiseType pollingInterval?: number keepPreviousData?: boolean + /** + * Max time before data from previous success is considered as outdated (in millisecond) + */ + maxDataLifetime?: number } interface Context { @@ -133,7 +137,7 @@ const DataLoaderProvider = ({ [computeKey(key)]: newRequest, })) - addReload(key, newRequest.launch) + addReload(key, () => newRequest.load(true)) return newRequest } diff --git a/packages/use-dataloader/src/__tests__/dataloader.test.ts b/packages/use-dataloader/src/__tests__/dataloader.test.ts index ee6bdffec..e480a41da 100644 --- a/packages/use-dataloader/src/__tests__/dataloader.test.ts +++ b/packages/use-dataloader/src/__tests__/dataloader.test.ts @@ -14,7 +14,7 @@ const fakeErrorPromise = () => }) describe('Dataloader class', () => { - test('should create instance not enabled then launch then destroy', async () => { + test('should create instance not enabled then load then destroy', async () => { const notify = jest.fn() const method = jest.fn(fakeSuccessPromise) const instance = new DataLoader({ @@ -25,13 +25,13 @@ describe('Dataloader class', () => { expect(instance.status).toBe(StatusEnum.IDLE) expect(notify).toBeCalledTimes(0) expect(method).toBeCalledTimes(0) - await instance.launch() + await instance.load() expect(method).toBeCalledTimes(1) expect(notify).toBeCalledTimes(2) instance.destroy() }) - test('should create instance enabled then launch', async () => { + test('should create instance enabled then load', async () => { const notify = jest.fn() const method = jest.fn() const instance = new DataLoader({ @@ -43,16 +43,35 @@ describe('Dataloader class', () => { expect(instance.status).toBe(StatusEnum.LOADING) expect(notify).toBeCalledTimes(0) expect(method).toBeCalledTimes(0) - await instance.launch() + await instance.load() // This does nothing because no cancel listener is set await instance.cancel() expect(method).toBeCalledTimes(1) expect(notify).toBeCalledTimes(1) }) - test('should create instance with cancel listener', async () => { + test('should create instance with cancel listener and success', async () => { const notify = jest.fn() - const method = jest.fn() + const method = jest.fn(fakeSuccessPromise) + const onCancel = jest.fn() + const instance = new DataLoader({ + key: 'test', + method, + notify, + }) + instance.addOnCancelListener(onCancel) + instance.addOnCancelListener(onCancel) + // eslint-disable-next-line no-void + void instance.load() + await instance.cancel() + expect(onCancel).toBeCalledTimes(1) + instance.removeOnCancelListener(onCancel) + instance.removeOnCancelListener(onCancel) + }) + + test('should create instance with cancel listener and error', async () => { + const notify = jest.fn() + const method = jest.fn(fakeErrorPromise) const onCancel = jest.fn() const instance = new DataLoader({ key: 'test', @@ -62,7 +81,7 @@ describe('Dataloader class', () => { instance.addOnCancelListener(onCancel) instance.addOnCancelListener(onCancel) // eslint-disable-next-line no-void - void instance.launch() + void instance.load() await instance.cancel() expect(onCancel).toBeCalledTimes(1) instance.removeOnCancelListener(onCancel) @@ -80,7 +99,7 @@ describe('Dataloader class', () => { }) instance.addOnSuccessListener(onSuccess) instance.addOnSuccessListener(onSuccess) - await instance.launch() + await instance.load() expect(onSuccess).toBeCalledTimes(1) instance.removeOnSuccessListener(onSuccess) instance.removeOnSuccessListener(onSuccess) @@ -97,7 +116,7 @@ describe('Dataloader class', () => { }) instance.addOnErrorListener(onError) instance.addOnErrorListener(onError) - await instance.launch() + await instance.load() expect(onError).toBeCalledTimes(1) expect(instance.error?.message).toBe('test') instance.removeOnErrorListener(onError) @@ -113,13 +132,46 @@ describe('Dataloader class', () => { notify, pollingInterval: PROMISE_TIMEOUT, }) - await instance.launch() + await instance.load() expect(method).toBeCalledTimes(1) - await instance.launch() + await new Promise(resolve => setTimeout(resolve, PROMISE_TIMEOUT * 2)) expect(method).toBeCalledTimes(2) await new Promise(resolve => setTimeout(resolve, PROMISE_TIMEOUT * 2)) expect(method).toBeCalledTimes(3) - await instance.launch() + await instance.load() + await instance.load() + expect(method).toBeCalledTimes(4) + await instance.load() + await instance.load() + await instance.load(true) + expect(method).toBeCalledTimes(6) instance.destroy() }) + + test('should update outdated data', async () => { + const notify = jest.fn() + const method = jest.fn(fakeSuccessPromise) + const onSuccess = jest.fn() + const instance = new DataLoader({ + enabled: true, + key: 'test', + maxDataLifetime: PROMISE_TIMEOUT, + method, + notify, + }) + instance.addOnSuccessListener(onSuccess) + expect(instance.status).toBe(StatusEnum.LOADING) + expect(method).toBeCalledTimes(0) + expect(onSuccess).toBeCalledTimes(0) + await instance.load() + expect(method).toBeCalledTimes(1) + expect(onSuccess).toBeCalledTimes(1) + await instance.load() + expect(method).toBeCalledTimes(1) + expect(onSuccess).toBeCalledTimes(1) + await new Promise(resolve => setTimeout(resolve, PROMISE_TIMEOUT * 2)) + await instance.load() + expect(method).toBeCalledTimes(2) + expect(onSuccess).toBeCalledTimes(2) + }) }) diff --git a/packages/use-dataloader/src/dataloader.ts b/packages/use-dataloader/src/dataloader.ts index 79ab88e99..a624f8230 100644 --- a/packages/use-dataloader/src/dataloader.ts +++ b/packages/use-dataloader/src/dataloader.ts @@ -6,6 +6,7 @@ export type DataLoaderConstructorArgs = { key: string method: () => PromiseType pollingInterval?: number + maxDataLifetime?: number keepPreviousData?: boolean notify: (updatedRequest: DataLoader) => void } @@ -17,12 +18,18 @@ class DataLoader { public pollingInterval?: number + public maxDataLifetime?: number + + public isDataOutdated = false + private notify: (updatedRequest: DataLoader) => void public method: () => PromiseType private cancelMethod?: () => void + private canceled = false + public keepPreviousData?: boolean private errorListeners: Array = [] @@ -33,6 +40,8 @@ class DataLoader { public error?: Error + private dataOutdatedTimeout?: number + public timeout?: number public constructor(args: DataLoaderConstructorArgs) { @@ -42,15 +51,27 @@ class DataLoader { this.pollingInterval = args?.pollingInterval this.keepPreviousData = args?.keepPreviousData this.notify = args.notify + this.maxDataLifetime = args.maxDataLifetime } - public launch = async (): Promise => { - try { + public load = async (force = false): Promise => { + if ( + force || + this.status !== StatusEnum.SUCCESS || + (this.status === StatusEnum.SUCCESS && this.isDataOutdated) + ) { if (this.timeout) { // Prevent multiple call at the same time clearTimeout(this.timeout) } + await this.launch() + } + } + + private launch = async (): Promise => { + try { if (this.status !== StatusEnum.LOADING) { + this.canceled = false this.status = StatusEnum.LOADING this.notify(this) } @@ -61,16 +82,31 @@ class DataLoader { this.status = StatusEnum.SUCCESS this.error = undefined this.notify(this) - await Promise.all( - this.successListeners.map(listener => listener?.(result)), - ) + if (!this.canceled) { + await Promise.all( + this.successListeners.map(listener => listener?.(result)), + ) + + this.isDataOutdated = false + if (this.dataOutdatedTimeout) { + clearTimeout(this.dataOutdatedTimeout) + this.dataOutdatedTimeout = undefined + } + if (this.maxDataLifetime) { + this.dataOutdatedTimeout = setTimeout(() => { + this.isDataOutdated = true + }, this.maxDataLifetime) as unknown as number + } + } } catch (err) { this.status = StatusEnum.ERROR this.error = err as Error this.notify(this) - await Promise.all( - this.errorListeners.map(listener => listener?.(err as Error)), - ) + if (!this.canceled) { + await Promise.all( + this.errorListeners.map(listener => listener?.(err as Error)), + ) + } } if (this.pollingInterval) { this.timeout = setTimeout( @@ -82,6 +118,7 @@ class DataLoader { } public cancel = async (): Promise => { + this.canceled = true this.cancelMethod?.() await Promise.all(this.cancelListeners.map(listener => listener?.())) } diff --git a/packages/use-dataloader/src/types.ts b/packages/use-dataloader/src/types.ts index 4744f1f9f..c86b24355 100644 --- a/packages/use-dataloader/src/types.ts +++ b/packages/use-dataloader/src/types.ts @@ -24,6 +24,10 @@ export interface UseDataLoaderConfig { onError?: OnErrorFn onSuccess?: OnSuccessFn pollingInterval?: number + /** + * Max time before data from previous success is considered as outdated (in millisecond) + */ + maxDataLifetime?: number } /** diff --git a/packages/use-dataloader/src/useDataLoader.ts b/packages/use-dataloader/src/useDataLoader.ts index 6f5b7f8a5..2a18fccc1 100644 --- a/packages/use-dataloader/src/useDataLoader.ts +++ b/packages/use-dataloader/src/useDataLoader.ts @@ -19,6 +19,7 @@ const useDataLoader = ( onError, onSuccess, pollingInterval, + maxDataLifetime, }: UseDataLoaderConfig = {}, ): UseDataLoaderResult => { const { @@ -38,16 +39,24 @@ const useDataLoader = ( getRequest(fetchKey) ?? addRequest(fetchKey, { key: fetchKey, + maxDataLifetime, method, pollingInterval, }), - [addRequest, fetchKey, getRequest, method, pollingInterval], + [ + addRequest, + fetchKey, + getRequest, + method, + pollingInterval, + maxDataLifetime, + ], ) useEffect(() => { if (enabled && request.status === StatusEnum.IDLE) { // eslint-disable-next-line no-void - void request.launch() + void request.load() } }, [request, enabled]) @@ -128,7 +137,7 @@ const useDataLoader = ( isPolling, isSuccess, previousData: previousDataRef.current, - reload: request.launch, + reload: () => request.load(true), } }