From 06352779a346b9339260fc762eb194c55a2f317b Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Tue, 7 Dec 2021 12:09:56 +0100 Subject: [PATCH 1/5] refactor(core): refactor retryer from a class to a function --- src/core/mutation.ts | 6 +- src/core/query.ts | 6 +- src/core/retryer.ts | 274 +++++++++++++++++++++---------------------- 3 files changed, 143 insertions(+), 143 deletions(-) diff --git a/src/core/mutation.ts b/src/core/mutation.ts index 093f4107ee..04533c11ea 100644 --- a/src/core/mutation.ts +++ b/src/core/mutation.ts @@ -4,7 +4,7 @@ import type { MutationObserver } from './mutationObserver' import { getLogger } from './logger' import { notifyManager } from './notifyManager' import { Removable } from './removable' -import { canFetch, Retryer } from './retryer' +import { canFetch, Retryer, createRetryer } from './retryer' import { noop } from './utils' // TYPES @@ -90,7 +90,7 @@ export class Mutation< private observers: MutationObserver[] private mutationCache: MutationCache - private retryer?: Retryer + private retryer?: Retryer constructor(config: MutationConfig) { super() @@ -262,7 +262,7 @@ export class Mutation< } private executeMutation(): Promise { - this.retryer = new Retryer({ + this.retryer = createRetryer({ fn: () => { if (!this.options.mutationFn) { return Promise.reject('No mutationFn found') diff --git a/src/core/query.ts b/src/core/query.ts index 12e99622ed..07e171ecc7 100644 --- a/src/core/query.ts +++ b/src/core/query.ts @@ -21,7 +21,7 @@ import type { QueryCache } from './queryCache' import type { QueryObserver } from './queryObserver' import { notifyManager } from './notifyManager' import { getLogger } from './logger' -import { Retryer, isCancelledError, canFetch } from './retryer' +import { Retryer, isCancelledError, canFetch, createRetryer } from './retryer' import { Removable } from './removable' // TYPES @@ -158,7 +158,7 @@ export class Query< private cache: QueryCache private promise?: Promise - private retryer?: Retryer + private retryer?: Retryer private observers: QueryObserver[] private defaultOptions?: QueryOptions private abortSignalConsumed: boolean @@ -431,7 +431,7 @@ export class Query< } // Try to fetch the data - this.retryer = new Retryer({ + this.retryer = createRetryer({ fn: context.fetchFn as () => TData, abort: abortController?.abort?.bind(abortController), onSuccess: data => { diff --git a/src/core/retryer.ts b/src/core/retryer.ts index 537f1b28ab..0b5f8bf85c 100644 --- a/src/core/retryer.ts +++ b/src/core/retryer.ts @@ -18,6 +18,14 @@ interface RetryerConfig { networkMode: NetworkMode | undefined } +export interface Retryer { + promise: Promise + cancel: (cancelOptions?: CancelOptions) => void + continue: () => void + cancelRetry: () => void + continueRetry: () => void +} + export type RetryValue = boolean | number | ShouldRetryFunction type ShouldRetryFunction = ( @@ -55,160 +63,152 @@ export function isCancelledError(value: any): value is CancelledError { return value instanceof CancelledError } -// CLASS - -export class Retryer { - cancel: (options?: CancelOptions) => void - cancelRetry: () => void - continueRetry: () => void - continue: () => void - failureCount: number - isPaused: boolean - isResolved: boolean - promise: Promise - - constructor(config: RetryerConfig) { - let cancelRetry = false - let continueFn: ((value?: unknown) => void) | undefined - let promiseResolve: (data: TData) => void - let promiseReject: (error: TError) => void - - this.cancel = (cancelOptions?: CancelOptions): void => { - if (!this.isResolved) { - reject(new CancelledError(cancelOptions)) - - config.abort?.() - } - } - this.cancelRetry = () => { - cancelRetry = true +export function createRetryer( + config: RetryerConfig +): Retryer { + let isRetryCancelled = false + let failureCount = 0 + let isResolved = false + const promise = new Promise((outerResolve, outerReject) => { + promiseResolve = outerResolve + promiseReject = outerReject + }) + let continueFn: ((value?: unknown) => void) | undefined + let promiseResolve: (data: TData) => void + let promiseReject: (error: TError) => void + + const cancel = (cancelOptions?: CancelOptions): void => { + if (!isResolved) { + reject(new CancelledError(cancelOptions)) + + config.abort?.() } + } + const cancelRetry = () => { + isRetryCancelled = true + } - this.continueRetry = () => { - cancelRetry = false - } + const continueRetry = () => { + isRetryCancelled = false + } - const shouldPause = () => - !focusManager.isFocused() || - (config.networkMode !== 'always' && !onlineManager.isOnline()) + const shouldPause = () => + !focusManager.isFocused() || + (config.networkMode !== 'always' && !onlineManager.isOnline()) - this.continue = () => { + const resolve = (value: any) => { + if (!isResolved) { + isResolved = true + config.onSuccess?.(value) continueFn?.() + promiseResolve(value) } - this.failureCount = 0 - this.isPaused = false - this.isResolved = false - this.promise = new Promise((outerResolve, outerReject) => { - promiseResolve = outerResolve - promiseReject = outerReject - }) + } - const resolve = (value: any) => { - if (!this.isResolved) { - this.isResolved = true - config.onSuccess?.(value) - continueFn?.() - promiseResolve(value) - } + const reject = (value: any) => { + if (!isResolved) { + isResolved = true + config.onError?.(value) + continueFn?.() + promiseReject(value) } + } - const reject = (value: any) => { - if (!this.isResolved) { - this.isResolved = true - config.onError?.(value) - continueFn?.() - promiseReject(value) + const pause = () => { + return new Promise(continueResolve => { + continueFn = value => { + if (isResolved || !shouldPause()) { + return continueResolve(value) + } + } + config.onPause?.() + }).then(() => { + continueFn = undefined + if (!isResolved) { + config.onContinue?.() } + }) + } + + // Create loop function + const run = () => { + // Do nothing if already resolved + if (isResolved) { + return } - const pause = () => { - return new Promise(continueResolve => { - continueFn = value => { - if (this.isResolved || !shouldPause()) { - return continueResolve(value) - } - } - this.isPaused = true - config.onPause?.() - }).then(() => { - continueFn = undefined - this.isPaused = false - if (!this.isResolved) { - config.onContinue?.() - } - }) + let promiseOrValue: any + + // Execute query + try { + promiseOrValue = config.fn() + } catch (error) { + promiseOrValue = Promise.reject(error) } - // Create loop function - const run = () => { - // Do nothing if already resolved - if (this.isResolved) { - return - } + Promise.resolve(promiseOrValue) + .then(resolve) + .catch(error => { + // Stop if the fetch is already resolved + if (isResolved) { + return + } - let promiseOrValue: any + // Do we need to retry the request? + const retry = config.retry ?? 3 + const retryDelay = config.retryDelay ?? defaultRetryDelay + const delay = + typeof retryDelay === 'function' + ? retryDelay(failureCount, error) + : retryDelay + const shouldRetry = + retry === true || + (typeof retry === 'number' && failureCount < retry) || + (typeof retry === 'function' && retry(failureCount, error)) + + if (isRetryCancelled || !shouldRetry) { + // We are done if the query does not need to be retried + reject(error) + return + } - // Execute query - try { - promiseOrValue = config.fn() - } catch (error) { - promiseOrValue = Promise.reject(error) - } + failureCount++ + + // Notify on fail + config.onFail?.(failureCount, error) + + // Delay + sleep(delay) + // Pause if the document is not visible or when the device is offline + .then(() => { + if (shouldPause()) { + return pause() + } + }) + .then(() => { + if (isRetryCancelled) { + reject(error) + } else { + run() + } + }) + }) + } - Promise.resolve(promiseOrValue) - .then(resolve) - .catch(error => { - // Stop if the fetch is already resolved - if (this.isResolved) { - return - } - - // Do we need to retry the request? - const retry = config.retry ?? 3 - const retryDelay = config.retryDelay ?? defaultRetryDelay - const delay = - typeof retryDelay === 'function' - ? retryDelay(this.failureCount, error) - : retryDelay - const shouldRetry = - retry === true || - (typeof retry === 'number' && this.failureCount < retry) || - (typeof retry === 'function' && retry(this.failureCount, error)) - - if (cancelRetry || !shouldRetry) { - // We are done if the query does not need to be retried - reject(error) - return - } - - this.failureCount++ - - // Notify on fail - config.onFail?.(this.failureCount, error) - - // Delay - sleep(delay) - // Pause if the document is not visible or when the device is offline - .then(() => { - if (shouldPause()) { - return pause() - } - }) - .then(() => { - if (cancelRetry) { - reject(error) - } else { - run() - } - }) - }) - } + // Start loop + if (canFetch(config.networkMode)) { + run() + } else { + pause().then(run) + } - // Start loop - if (canFetch(config.networkMode)) { - run() - } else { - pause().then(run) - } + return { + promise, + cancel, + continue: () => { + continueFn?.() + }, + cancelRetry, + continueRetry, } } From 089a7516cf61315af7f989fe07fc5530f6c4dac7 Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Tue, 7 Dec 2021 15:18:19 +0100 Subject: [PATCH 2/5] refactor(core): refactor notifyManager from a class to a function --- src/core/notifyManager.ts | 80 +++++++++++++-------------- src/core/tests/notifyManager.test.tsx | 6 +- 2 files changed, 42 insertions(+), 44 deletions(-) diff --git a/src/core/notifyManager.ts b/src/core/notifyManager.ts index b584f7bf84..07bec3b558 100644 --- a/src/core/notifyManager.ts +++ b/src/core/notifyManager.ts @@ -10,41 +10,32 @@ type BatchNotifyFunction = (callback: () => void) => void // CLASS -export class NotifyManager { - private queue: NotifyCallback[] - private transactions: number - private notifyFn: NotifyFunction - private batchNotifyFn: BatchNotifyFunction - - constructor() { - this.queue = [] - this.transactions = 0 - - this.notifyFn = (callback: () => void) => { - callback() - } - - this.batchNotifyFn = (callback: () => void) => { - callback() - } +export function createNotifyManager() { + let queue: NotifyCallback[] = [] + let transactions = 0 + let notifyFn: NotifyFunction = callback => { + callback() + } + let batchNotifyFn: BatchNotifyFunction = (callback: () => void) => { + callback() } - batch(callback: () => T): T { - this.transactions++ + const batch = (callback: () => T): T => { + transactions++ const result = callback() - this.transactions-- - if (!this.transactions) { - this.flush() + transactions-- + if (!transactions) { + flush() } return result } - schedule(callback: NotifyCallback): void { - if (this.transactions) { - this.queue.push(callback) + const schedule = (callback: NotifyCallback): void => { + if (transactions) { + queue.push(callback) } else { scheduleMicrotask(() => { - this.notifyFn(callback) + notifyFn(callback) }) } } @@ -52,22 +43,22 @@ export class NotifyManager { /** * All calls to the wrapped function will be batched. */ - batchCalls(callback: T): T { + const batchCalls = (callback: T): T => { return ((...args: any[]) => { - this.schedule(() => { + schedule(() => { callback(...args) }) }) as any } - flush(): void { - const queue = this.queue - this.queue = [] - if (queue.length) { + const flush = (): void => { + const originalQueue = queue + queue = [] + if (originalQueue.length) { scheduleMicrotask(() => { - this.batchNotifyFn(() => { - queue.forEach(callback => { - this.notifyFn(callback) + batchNotifyFn(() => { + originalQueue.forEach(callback => { + notifyFn(callback) }) }) }) @@ -78,19 +69,26 @@ export class NotifyManager { * Use this method to set a custom notify function. * This can be used to for example wrap notifications with `React.act` while running tests. */ - setNotifyFunction(fn: NotifyFunction) { - this.notifyFn = fn + const setNotifyFunction = (fn: NotifyFunction) => { + notifyFn = fn } /** * Use this method to set a custom function to batch notifications together into a single tick. * By default React Query will use the batch function provided by ReactDOM or React Native. */ - setBatchNotifyFunction(fn: BatchNotifyFunction) { - this.batchNotifyFn = fn + const setBatchNotifyFunction = (fn: BatchNotifyFunction) => { + batchNotifyFn = fn } + + return { + batch, + batchCalls, + schedule, + setNotifyFunction, + setBatchNotifyFunction, + } as const } // SINGLETON - -export const notifyManager = new NotifyManager() +export const notifyManager = createNotifyManager() diff --git a/src/core/tests/notifyManager.test.tsx b/src/core/tests/notifyManager.test.tsx index 53816aa767..fc24c2c8c1 100644 --- a/src/core/tests/notifyManager.test.tsx +++ b/src/core/tests/notifyManager.test.tsx @@ -1,9 +1,9 @@ -import { NotifyManager } from '../notifyManager' +import { createNotifyManager } from '../notifyManager' import { sleep } from '../../devtools/tests/utils' describe('notifyManager', () => { it('should use default notifyFn', async () => { - const notifyManagerTest = new NotifyManager() + const notifyManagerTest = createNotifyManager() const callbackSpy = jest.fn() notifyManagerTest.schedule(callbackSpy) await sleep(1) @@ -11,7 +11,7 @@ describe('notifyManager', () => { }) it('should use default batchNotifyFn', async () => { - const notifyManagerTest = new NotifyManager() + const notifyManagerTest = createNotifyManager() const callbackScheduleSpy = jest .fn() .mockImplementation(async () => await sleep(20)) From 96b7895a4811fa0b654438a13662d06bd90108fe Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Tue, 7 Dec 2021 15:37:55 +0100 Subject: [PATCH 3/5] refactor(core): refactor notifyManager from a class to a function remove outdated comment --- src/core/notifyManager.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/core/notifyManager.ts b/src/core/notifyManager.ts index 07bec3b558..dc1e7a0cab 100644 --- a/src/core/notifyManager.ts +++ b/src/core/notifyManager.ts @@ -8,8 +8,6 @@ type NotifyFunction = (callback: () => void) => void type BatchNotifyFunction = (callback: () => void) => void -// CLASS - export function createNotifyManager() { let queue: NotifyCallback[] = [] let transactions = 0 From f1cb1a66a65423201ff8f5998a3b89e0c80a32df Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Wed, 8 Dec 2021 22:00:51 +0100 Subject: [PATCH 4/5] refactor(core): unify focusManager and onlineManager into an eventManager, which is function based --- src/core/eventManager.ts | 78 ++++++++++++++++++++++ src/core/focusManager.ts | 95 +++++++-------------------- src/core/onlineManager.ts | 95 +++++++-------------------- src/core/tests/focusManager.test.tsx | 12 ++-- src/core/tests/onlineManager.test.tsx | 12 ++-- 5 files changed, 140 insertions(+), 152 deletions(-) create mode 100644 src/core/eventManager.ts diff --git a/src/core/eventManager.ts b/src/core/eventManager.ts new file mode 100644 index 0000000000..be94551dd9 --- /dev/null +++ b/src/core/eventManager.ts @@ -0,0 +1,78 @@ +import { isServer } from './utils' + +type ListenerFn = () => void + +export function createEventManager( + events: ReadonlyArray[0]> +) { + let value: boolean | undefined + let removeEventListener: ListenerFn | undefined + let listeners: ListenerFn[] = [] + let setupFn: ( + param: (newValue?: boolean) => void + ) => ListenerFn | undefined = onEvent => { + if (!isServer && window?.addEventListener) { + const listener = () => onEvent() + events.forEach(eventName => { + window.addEventListener(eventName, listener, false) + }) + + return () => { + events.forEach(eventName => { + window.removeEventListener(eventName, listener) + }) + } + } + } + + const subscribe = (listener: ListenerFn): ListenerFn => { + listeners.push(listener) + + if (!removeEventListener) { + setEventListener(setupFn) + } + + return () => { + listeners = listeners.filter(x => x !== listener) + if (listeners.length === 0) { + removeEventListener?.() + removeEventListener = undefined + } + } + } + + const setValue = (newValue?: boolean): void => { + value = newValue + + if (newValue) { + onEvent() + } + } + + const setEventListener = ( + setup: (param: (newValue?: boolean) => void) => ListenerFn | undefined + ): void => { + removeEventListener?.() + setupFn = setup + removeEventListener = setupFn(newValue => { + if (typeof newValue === 'boolean') { + setValue(newValue) + } else { + onEvent() + } + }) + } + + const onEvent = (): void => { + listeners.forEach(listener => { + listener() + }) + } + + return { + setEventListener, + subscribe, + setValue, + getValue: () => value, + } as const +} diff --git a/src/core/focusManager.ts b/src/core/focusManager.ts index 18bfdcbc46..7a98cf03a2 100644 --- a/src/core/focusManager.ts +++ b/src/core/focusManager.ts @@ -1,76 +1,31 @@ -import { Subscribable } from './subscribable' -import { isServer } from './utils' - -class FocusManager extends Subscribable { - private focused?: boolean - private removeEventListener?: () => void - - protected onSubscribe(): void { - if (!this.removeEventListener) { - this.setDefaultEventListener() - } - } - - setEventListener( - setup: (setFocused: (focused?: boolean) => void) => () => void - ): void { - if (this.removeEventListener) { - this.removeEventListener() - } - this.removeEventListener = setup(focused => { - if (typeof focused === 'boolean') { - this.setFocused(focused) - } else { - this.onFocus() +import { createEventManager } from './eventManager' + +export const createFocusManager = () => { + const { setEventListener, subscribe, ...manager } = createEventManager([ + 'visibilitychange', + 'focus', + ]) + + return { + subscribe, + setEventListener, + setFocused: manager.setValue, + isFocused: (): boolean => { + const value = manager.getValue() + if (typeof value === 'boolean') { + return value } - }) - } - - setFocused(focused?: boolean): void { - this.focused = focused - - if (focused) { - this.onFocus() - } - } - onFocus(): void { - this.listeners.forEach(listener => { - listener() - }) - } - - isFocused(): boolean { - if (typeof this.focused === 'boolean') { - return this.focused - } - - // document global can be unavailable in react native - if (typeof document === 'undefined') { - return true - } - - return [undefined, 'visible', 'prerender'].includes( - document.visibilityState - ) - } - - private setDefaultEventListener() { - if (!isServer && window?.addEventListener) { - this.setEventListener(onFocus => { - const listener = () => onFocus() - // Listen to visibillitychange and focus - window.addEventListener('visibilitychange', listener, false) - window.addEventListener('focus', listener, false) + // document global can be unavailable in react native + if (typeof document === 'undefined') { + return true + } - return () => { - // Be sure to unsubscribe if a new handler is set - window.removeEventListener('visibilitychange', listener) - window.removeEventListener('focus', listener) - } - }) - } + return [undefined, 'visible', 'prerender'].includes( + document.visibilityState + ) + }, } } -export const focusManager = new FocusManager() +export const focusManager = createFocusManager() diff --git a/src/core/onlineManager.ts b/src/core/onlineManager.ts index f02a03c9bf..5c3f45cdeb 100644 --- a/src/core/onlineManager.ts +++ b/src/core/onlineManager.ts @@ -1,76 +1,31 @@ -import { Subscribable } from './subscribable' -import { isServer } from './utils' - -class OnlineManager extends Subscribable { - private online?: boolean - private removeEventListener?: () => void - - protected onSubscribe(): void { - if (!this.removeEventListener) { - this.setDefaultEventListener() - } - } - - setEventListener( - setup: (setOnline: (online?: boolean) => void) => () => void - ): void { - if (this.removeEventListener) { - this.removeEventListener() - } - this.removeEventListener = setup((online?: boolean) => { - if (typeof online === 'boolean') { - this.setOnline(online) - } else { - this.onOnline() +import { createEventManager } from './eventManager' + +export const createOnlineManager = () => { + const { setEventListener, subscribe, ...manager } = createEventManager([ + 'online', + 'offline', + ]) + + return { + subscribe, + setEventListener, + setOnline: manager.setValue, + isOnline: (): boolean => { + const value = manager.getValue() + if (typeof value === 'boolean') { + return value } - }) - } - - setOnline(online?: boolean): void { - this.online = online - - if (online) { - this.onOnline() - } - } - onOnline(): void { - this.listeners.forEach(listener => { - listener() - }) - } - - isOnline(): boolean { - if (typeof this.online === 'boolean') { - return this.online - } - - if ( - typeof navigator === 'undefined' || - typeof navigator.onLine === 'undefined' - ) { - return true - } - - return navigator.onLine - } - - private setDefaultEventListener() { - if (!isServer && window?.addEventListener) { - this.setEventListener(onOnline => { - const listener = () => onOnline() - // Listen to online - window.addEventListener('online', listener, false) - window.addEventListener('offline', listener, false) + if ( + typeof navigator === 'undefined' || + typeof navigator.onLine === 'undefined' + ) { + return true + } - return () => { - // Be sure to unsubscribe if a new handler is set - window.removeEventListener('online', listener) - window.removeEventListener('offline', listener) - } - }) - } + return navigator.onLine + }, } } -export const onlineManager = new OnlineManager() +export const onlineManager = createOnlineManager() diff --git a/src/core/tests/focusManager.test.tsx b/src/core/tests/focusManager.test.tsx index ca071ec16d..1d45c81cdd 100644 --- a/src/core/tests/focusManager.test.tsx +++ b/src/core/tests/focusManager.test.tsx @@ -1,10 +1,10 @@ import { sleep } from '../utils' -import { focusManager } from '../focusManager' +import { createFocusManager } from '../focusManager' describe('focusManager', () => { - afterEach(() => { - // Reset removeEventListener private property to avoid side effects between tests - focusManager['removeEventListener'] = undefined + let focusManager: ReturnType + beforeEach(() => { + focusManager = createFocusManager() }) it('should call previous remove handler when replacing an event listener', () => { @@ -69,7 +69,7 @@ describe('focusManager', () => { const setEventListenerSpy = jest.spyOn(focusManager, 'setEventListener') - const unsubscribe = focusManager.subscribe() + const unsubscribe = focusManager.subscribe(() => undefined) expect(setEventListenerSpy).toHaveBeenCalledTimes(0) unsubscribe() @@ -88,7 +88,7 @@ describe('focusManager', () => { ) // Should set the default event listener with window event listeners - const unsubscribe = focusManager.subscribe() + const unsubscribe = focusManager.subscribe(() => undefined) expect(addEventListenerSpy).toHaveBeenCalledTimes(2) // Should replace the window default event listener by a new one diff --git a/src/core/tests/onlineManager.test.tsx b/src/core/tests/onlineManager.test.tsx index 2e7d35c3f4..b7e36b6e9d 100644 --- a/src/core/tests/onlineManager.test.tsx +++ b/src/core/tests/onlineManager.test.tsx @@ -1,10 +1,10 @@ -import { onlineManager } from '../onlineManager' +import { createOnlineManager } from '../onlineManager' import { sleep } from '../utils' describe('onlineManager', () => { - afterEach(() => { - // Reset removeEventListener private property to avoid side effects between tests - onlineManager['removeEventListener'] = undefined + let onlineManager: ReturnType + beforeEach(() => { + onlineManager = createOnlineManager() }) test('isOnline should return true if navigator is undefined', () => { @@ -64,7 +64,7 @@ describe('onlineManager', () => { const setEventListenerSpy = jest.spyOn(onlineManager, 'setEventListener') - const unsubscribe = onlineManager.subscribe() + const unsubscribe = onlineManager.subscribe(() => undefined) expect(setEventListenerSpy).toHaveBeenCalledTimes(0) unsubscribe() @@ -83,7 +83,7 @@ describe('onlineManager', () => { ) // Should set the default event listener with window event listeners - const unsubscribe = onlineManager.subscribe() + const unsubscribe = onlineManager.subscribe(() => undefined) expect(addEventListenerSpy).toHaveBeenCalledTimes(2) // Should replace the window default event listener by a new one From 217e182d326208a4c131af856fd153f6faffc7c0 Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Wed, 8 Dec 2021 22:22:22 +0100 Subject: [PATCH 5/5] Revert "refactor(core): unify focusManager and onlineManager" This reverts commit f1cb1a66a65423201ff8f5998a3b89e0c80a32df. --- src/core/eventManager.ts | 78 ---------------------- src/core/focusManager.ts | 95 ++++++++++++++++++++------- src/core/onlineManager.ts | 95 ++++++++++++++++++++------- src/core/tests/focusManager.test.tsx | 12 ++-- src/core/tests/onlineManager.test.tsx | 12 ++-- 5 files changed, 152 insertions(+), 140 deletions(-) delete mode 100644 src/core/eventManager.ts diff --git a/src/core/eventManager.ts b/src/core/eventManager.ts deleted file mode 100644 index be94551dd9..0000000000 --- a/src/core/eventManager.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { isServer } from './utils' - -type ListenerFn = () => void - -export function createEventManager( - events: ReadonlyArray[0]> -) { - let value: boolean | undefined - let removeEventListener: ListenerFn | undefined - let listeners: ListenerFn[] = [] - let setupFn: ( - param: (newValue?: boolean) => void - ) => ListenerFn | undefined = onEvent => { - if (!isServer && window?.addEventListener) { - const listener = () => onEvent() - events.forEach(eventName => { - window.addEventListener(eventName, listener, false) - }) - - return () => { - events.forEach(eventName => { - window.removeEventListener(eventName, listener) - }) - } - } - } - - const subscribe = (listener: ListenerFn): ListenerFn => { - listeners.push(listener) - - if (!removeEventListener) { - setEventListener(setupFn) - } - - return () => { - listeners = listeners.filter(x => x !== listener) - if (listeners.length === 0) { - removeEventListener?.() - removeEventListener = undefined - } - } - } - - const setValue = (newValue?: boolean): void => { - value = newValue - - if (newValue) { - onEvent() - } - } - - const setEventListener = ( - setup: (param: (newValue?: boolean) => void) => ListenerFn | undefined - ): void => { - removeEventListener?.() - setupFn = setup - removeEventListener = setupFn(newValue => { - if (typeof newValue === 'boolean') { - setValue(newValue) - } else { - onEvent() - } - }) - } - - const onEvent = (): void => { - listeners.forEach(listener => { - listener() - }) - } - - return { - setEventListener, - subscribe, - setValue, - getValue: () => value, - } as const -} diff --git a/src/core/focusManager.ts b/src/core/focusManager.ts index 7a98cf03a2..18bfdcbc46 100644 --- a/src/core/focusManager.ts +++ b/src/core/focusManager.ts @@ -1,31 +1,76 @@ -import { createEventManager } from './eventManager' - -export const createFocusManager = () => { - const { setEventListener, subscribe, ...manager } = createEventManager([ - 'visibilitychange', - 'focus', - ]) - - return { - subscribe, - setEventListener, - setFocused: manager.setValue, - isFocused: (): boolean => { - const value = manager.getValue() - if (typeof value === 'boolean') { - return value - } +import { Subscribable } from './subscribable' +import { isServer } from './utils' + +class FocusManager extends Subscribable { + private focused?: boolean + private removeEventListener?: () => void + + protected onSubscribe(): void { + if (!this.removeEventListener) { + this.setDefaultEventListener() + } + } - // document global can be unavailable in react native - if (typeof document === 'undefined') { - return true + setEventListener( + setup: (setFocused: (focused?: boolean) => void) => () => void + ): void { + if (this.removeEventListener) { + this.removeEventListener() + } + this.removeEventListener = setup(focused => { + if (typeof focused === 'boolean') { + this.setFocused(focused) + } else { + this.onFocus() } + }) + } + + setFocused(focused?: boolean): void { + this.focused = focused + + if (focused) { + this.onFocus() + } + } + + onFocus(): void { + this.listeners.forEach(listener => { + listener() + }) + } + + isFocused(): boolean { + if (typeof this.focused === 'boolean') { + return this.focused + } + + // document global can be unavailable in react native + if (typeof document === 'undefined') { + return true + } + + return [undefined, 'visible', 'prerender'].includes( + document.visibilityState + ) + } + + private setDefaultEventListener() { + if (!isServer && window?.addEventListener) { + this.setEventListener(onFocus => { + const listener = () => onFocus() + // Listen to visibillitychange and focus + window.addEventListener('visibilitychange', listener, false) + window.addEventListener('focus', listener, false) - return [undefined, 'visible', 'prerender'].includes( - document.visibilityState - ) - }, + return () => { + // Be sure to unsubscribe if a new handler is set + window.removeEventListener('visibilitychange', listener) + window.removeEventListener('focus', listener) + } + }) + } } } -export const focusManager = createFocusManager() +export const focusManager = new FocusManager() diff --git a/src/core/onlineManager.ts b/src/core/onlineManager.ts index 5c3f45cdeb..f02a03c9bf 100644 --- a/src/core/onlineManager.ts +++ b/src/core/onlineManager.ts @@ -1,31 +1,76 @@ -import { createEventManager } from './eventManager' - -export const createOnlineManager = () => { - const { setEventListener, subscribe, ...manager } = createEventManager([ - 'online', - 'offline', - ]) - - return { - subscribe, - setEventListener, - setOnline: manager.setValue, - isOnline: (): boolean => { - const value = manager.getValue() - if (typeof value === 'boolean') { - return value - } +import { Subscribable } from './subscribable' +import { isServer } from './utils' + +class OnlineManager extends Subscribable { + private online?: boolean + private removeEventListener?: () => void + + protected onSubscribe(): void { + if (!this.removeEventListener) { + this.setDefaultEventListener() + } + } - if ( - typeof navigator === 'undefined' || - typeof navigator.onLine === 'undefined' - ) { - return true + setEventListener( + setup: (setOnline: (online?: boolean) => void) => () => void + ): void { + if (this.removeEventListener) { + this.removeEventListener() + } + this.removeEventListener = setup((online?: boolean) => { + if (typeof online === 'boolean') { + this.setOnline(online) + } else { + this.onOnline() } + }) + } + + setOnline(online?: boolean): void { + this.online = online + + if (online) { + this.onOnline() + } + } + + onOnline(): void { + this.listeners.forEach(listener => { + listener() + }) + } + + isOnline(): boolean { + if (typeof this.online === 'boolean') { + return this.online + } + + if ( + typeof navigator === 'undefined' || + typeof navigator.onLine === 'undefined' + ) { + return true + } + + return navigator.onLine + } + + private setDefaultEventListener() { + if (!isServer && window?.addEventListener) { + this.setEventListener(onOnline => { + const listener = () => onOnline() + // Listen to online + window.addEventListener('online', listener, false) + window.addEventListener('offline', listener, false) - return navigator.onLine - }, + return () => { + // Be sure to unsubscribe if a new handler is set + window.removeEventListener('online', listener) + window.removeEventListener('offline', listener) + } + }) + } } } -export const onlineManager = createOnlineManager() +export const onlineManager = new OnlineManager() diff --git a/src/core/tests/focusManager.test.tsx b/src/core/tests/focusManager.test.tsx index 1d45c81cdd..ca071ec16d 100644 --- a/src/core/tests/focusManager.test.tsx +++ b/src/core/tests/focusManager.test.tsx @@ -1,10 +1,10 @@ import { sleep } from '../utils' -import { createFocusManager } from '../focusManager' +import { focusManager } from '../focusManager' describe('focusManager', () => { - let focusManager: ReturnType - beforeEach(() => { - focusManager = createFocusManager() + afterEach(() => { + // Reset removeEventListener private property to avoid side effects between tests + focusManager['removeEventListener'] = undefined }) it('should call previous remove handler when replacing an event listener', () => { @@ -69,7 +69,7 @@ describe('focusManager', () => { const setEventListenerSpy = jest.spyOn(focusManager, 'setEventListener') - const unsubscribe = focusManager.subscribe(() => undefined) + const unsubscribe = focusManager.subscribe() expect(setEventListenerSpy).toHaveBeenCalledTimes(0) unsubscribe() @@ -88,7 +88,7 @@ describe('focusManager', () => { ) // Should set the default event listener with window event listeners - const unsubscribe = focusManager.subscribe(() => undefined) + const unsubscribe = focusManager.subscribe() expect(addEventListenerSpy).toHaveBeenCalledTimes(2) // Should replace the window default event listener by a new one diff --git a/src/core/tests/onlineManager.test.tsx b/src/core/tests/onlineManager.test.tsx index b7e36b6e9d..2e7d35c3f4 100644 --- a/src/core/tests/onlineManager.test.tsx +++ b/src/core/tests/onlineManager.test.tsx @@ -1,10 +1,10 @@ -import { createOnlineManager } from '../onlineManager' +import { onlineManager } from '../onlineManager' import { sleep } from '../utils' describe('onlineManager', () => { - let onlineManager: ReturnType - beforeEach(() => { - onlineManager = createOnlineManager() + afterEach(() => { + // Reset removeEventListener private property to avoid side effects between tests + onlineManager['removeEventListener'] = undefined }) test('isOnline should return true if navigator is undefined', () => { @@ -64,7 +64,7 @@ describe('onlineManager', () => { const setEventListenerSpy = jest.spyOn(onlineManager, 'setEventListener') - const unsubscribe = onlineManager.subscribe(() => undefined) + const unsubscribe = onlineManager.subscribe() expect(setEventListenerSpy).toHaveBeenCalledTimes(0) unsubscribe() @@ -83,7 +83,7 @@ describe('onlineManager', () => { ) // Should set the default event listener with window event listeners - const unsubscribe = onlineManager.subscribe(() => undefined) + const unsubscribe = onlineManager.subscribe() expect(addEventListenerSpy).toHaveBeenCalledTimes(2) // Should replace the window default event listener by a new one