From d4c07f2b280c7f7ae529718dd12e3e41836668ef Mon Sep 17 00:00:00 2001 From: Ian Ownbey Date: Tue, 24 Mar 2020 18:06:11 -0400 Subject: [PATCH] feat: Add ConnectionListener and a default browser implementation --- .../src/state/ConnectionListener.ts | 7 ++ .../src/state/DefaultConnectionListener.ts | 30 +++++ .../src/state/PollingSubscription.ts | 43 ++++--- .../src/state/__tests__/isOnline.ts | 16 --- .../state/__tests__/pollingSubscription.ts | 105 +++++++++++++----- packages/rest-hooks/src/state/isOnline.ts | 6 - 6 files changed, 143 insertions(+), 64 deletions(-) create mode 100644 packages/rest-hooks/src/state/ConnectionListener.ts create mode 100644 packages/rest-hooks/src/state/DefaultConnectionListener.ts delete mode 100644 packages/rest-hooks/src/state/__tests__/isOnline.ts delete mode 100644 packages/rest-hooks/src/state/isOnline.ts diff --git a/packages/rest-hooks/src/state/ConnectionListener.ts b/packages/rest-hooks/src/state/ConnectionListener.ts new file mode 100644 index 00000000000..7e8b916d867 --- /dev/null +++ b/packages/rest-hooks/src/state/ConnectionListener.ts @@ -0,0 +1,7 @@ +export interface ConnectionListener { + isOnline: () => boolean; + addOnlineListener: (handler: () => void) => void; + removeOnlineListener: (handler: () => void) => void; + addOfflineListener: (handler: () => void) => void; + removeOfflineListener: (handler: () => void) => void; +} diff --git a/packages/rest-hooks/src/state/DefaultConnectionListener.ts b/packages/rest-hooks/src/state/DefaultConnectionListener.ts new file mode 100644 index 00000000000..4b00436442b --- /dev/null +++ b/packages/rest-hooks/src/state/DefaultConnectionListener.ts @@ -0,0 +1,30 @@ +import { ConnectionListener } from './ConnectionListener'; + +export default class DefaultConnectionListener implements ConnectionListener { + isOnline() { + if (navigator.onLine !== undefined) { + return navigator.onLine; + } + return true; + } + + addOnlineListener(handler: () => void) { + if (typeof addEventListener === 'function') + addEventListener('online', handler); + } + + removeOnlineListener(handler: () => void) { + if (typeof removeEventListener === 'function') + removeEventListener('online', handler); + } + + addOfflineListener(handler: () => void) { + if (typeof addEventListener === 'function') + addEventListener('offline', handler); + } + + removeOfflineListener(handler: () => void) { + if (typeof removeEventListener === 'function') + removeEventListener('offline', handler); + } +} diff --git a/packages/rest-hooks/src/state/PollingSubscription.ts b/packages/rest-hooks/src/state/PollingSubscription.ts index 6f886ac0ce4..ac8afec7010 100644 --- a/packages/rest-hooks/src/state/PollingSubscription.ts +++ b/packages/rest-hooks/src/state/PollingSubscription.ts @@ -2,8 +2,9 @@ import { Schema } from 'rest-hooks/resource'; import { Dispatch } from '@rest-hooks/use-enhanced-reducer'; import { FETCH_TYPE, RECEIVE_TYPE } from 'rest-hooks/actionTypes'; -import isOnline from './isOnline'; import { Subscription, SubscriptionInit } from './SubscriptionManager'; +import DefaultConnectionListener from './DefaultConnectionListener'; +import { ConnectionListener } from './ConnectionListener'; /** * PollingSubscription keeps a given resource updated by @@ -19,10 +20,12 @@ export default class PollingSubscription implements Subscription { protected declare dispatch: Dispatch; protected declare intervalId?: NodeJS.Timeout; protected declare lastIntervalId?: NodeJS.Timeout; + private declare connectionListener: ConnectionListener; constructor( { url, schema, fetch, frequency }: SubscriptionInit, dispatch: Dispatch, + connectionListener?: ConnectionListener, ) { if (frequency === undefined) throw new Error('frequency needed for polling subscription'); @@ -32,8 +35,15 @@ export default class PollingSubscription implements Subscription { this.url = url; this.frequencyHistogram.set(this.frequency, 1); this.dispatch = dispatch; - if (isOnline()) this.update(); - this.run(); + this.connectionListener = + connectionListener || new DefaultConnectionListener(); + + // Kickstart running since this is initialized after the online notif is sent + if (this.connectionListener.isOnline()) { + this.onlineListener(); + } else { + this.offlineListener(); + } } /** Subscribe to a frequency */ @@ -98,11 +108,8 @@ export default class PollingSubscription implements Subscription { clearInterval(this.lastIntervalId); this.lastIntervalId = undefined; } - // react native does not support removeEventListener - if (typeof addEventListener === 'function') { - removeEventListener('online', this.onlineListener); - removeEventListener('offline', this.offlineListener); - } + this.connectionListener.removeOnlineListener(this.onlineListener); + this.connectionListener.removeOfflineListener(this.offlineListener); } /** Trigger request for latest resource */ @@ -128,13 +135,20 @@ export default class PollingSubscription implements Subscription { /** What happens when browser goes offline */ protected offlineListener = () => { this.cleanup(); - addEventListener('online', this.onlineListener); + this.connectionListener.removeOfflineListener(this.offlineListener); + this.connectionListener.addOnlineListener(this.onlineListener); }; /** What happens when browser comes online */ protected onlineListener = () => { - this.update(); - this.run(); + this.connectionListener.removeOnlineListener(this.onlineListener); + if (this.connectionListener.isOnline()) { + this.update(); + this.run(); + this.connectionListener.addOfflineListener(this.offlineListener); + } else { + this.connectionListener.addOnlineListener(this.onlineListener); + } }; /** Run polling process with current frequency @@ -142,7 +156,7 @@ export default class PollingSubscription implements Subscription { * Will clean up old poll interval on next run */ protected run() { - if (isOnline()) { + if (this.connectionListener.isOnline()) { this.lastIntervalId = this.intervalId; this.intervalId = setInterval(() => { // since we don't know how long into the last poll it was before resetting @@ -153,11 +167,6 @@ export default class PollingSubscription implements Subscription { } this.update(); }, this.frequency); - // react native does not support addEventListener - if (typeof addEventListener === 'function') - addEventListener('offline', this.offlineListener); - } else { - addEventListener('online', this.onlineListener); } } } diff --git a/packages/rest-hooks/src/state/__tests__/isOnline.ts b/packages/rest-hooks/src/state/__tests__/isOnline.ts deleted file mode 100644 index 96db5599259..00000000000 --- a/packages/rest-hooks/src/state/__tests__/isOnline.ts +++ /dev/null @@ -1,16 +0,0 @@ -import isOnline from '../isOnline'; - -describe('isOnline', () => { - it('should be true when navigator is not set', () => { - const oldValue = navigator.onLine; - Object.defineProperty(navigator, 'onLine', { - value: undefined, - writable: true, - }); - expect(isOnline()).toBe(true); - Object.defineProperty(navigator, 'onLine', { - value: oldValue, - writable: false, - }); - }); -}); diff --git a/packages/rest-hooks/src/state/__tests__/pollingSubscription.ts b/packages/rest-hooks/src/state/__tests__/pollingSubscription.ts index c626aa4de8c..46144487a15 100644 --- a/packages/rest-hooks/src/state/__tests__/pollingSubscription.ts +++ b/packages/rest-hooks/src/state/__tests__/pollingSubscription.ts @@ -1,7 +1,57 @@ import { PollingArticleResource } from '__tests__/common'; -import { mockEventHandlers } from '__tests__/utils'; import PollingSubscription from '../PollingSubscription'; +import { ConnectionListener } from '../ConnectionListener'; + +class MockConnectionListener implements ConnectionListener { + declare online: boolean; + declare onlineHandlers: (() => void)[]; + declare offlineHandlers: (() => void)[]; + + constructor(online: boolean) { + this.online = online; + this.onlineHandlers = []; + this.offlineHandlers = []; + } + + isOnline() { + return this.online; + } + + addOnlineListener(handler: () => void) { + this.onlineHandlers.push(handler); + } + + removeOnlineListener(handler: () => void) { + this.onlineHandlers = this.onlineHandlers.filter(h => h !== handler); + } + + addOfflineListener(handler: () => void) { + this.offlineHandlers.push(handler); + } + + removeOfflineListener(handler: () => void) { + this.offlineHandlers = this.offlineHandlers.filter(h => h !== handler); + } + + trigger(event: 'offline' | 'online') { + switch (event) { + case 'offline': + this.online = false; + this.offlineHandlers.forEach(t => t()); + break; + case 'online': + this.online = true; + this.onlineHandlers.forEach(t => t()); + break; + } + } + + reset() { + this.offlineHandlers = []; + this.onlineHandlers = []; + } +} function onError(e: any) { e.preventDefault(); @@ -169,19 +219,14 @@ describe('PollingSubscription', () => { }); describe('offline support', () => { - const dispatch = jest.fn(); - const a = () => Promise.resolve({ id: 5, title: 'hi' }); - const fetch = jest.fn(a); jest.useFakeTimers(); - const triggerEvent = mockEventHandlers(); - let sub: PollingSubscription; - - beforeAll(() => { - Object.defineProperty(navigator, 'onLine', { - value: false, - writable: true, - }); - sub = new PollingSubscription( + + function createMocks(listener: ConnectionListener) { + const dispatch = jest.fn(); + const a = () => Promise.resolve({ id: 5, title: 'hi' }); + const fetch = jest.fn(a); + + const pollingSubscription = new PollingSubscription( { url: 'test.com', schema: PollingArticleResource.getEntitySchema(), @@ -189,33 +234,43 @@ describe('PollingSubscription', () => { frequency: 5000, }, dispatch, + listener, ); - Object.defineProperty(navigator, 'onLine', { - value: true, - writable: false, - }); - }); - afterAll(() => { - sub.cleanup(); - }); + return { dispatch, fetch, pollingSubscription }; + } it('should not dispatch when offline', () => { + const listener = new MockConnectionListener(false); + const { dispatch } = createMocks(listener); jest.advanceTimersByTime(50000); expect(dispatch.mock.calls.length).toBe(0); + expect(listener.offlineHandlers.length).toBe(0); + expect(listener.onlineHandlers.length).toBe(1); }); it('should immediately start fetching when online', () => { - triggerEvent('online', new Event('online')); + const listener = new MockConnectionListener(false); + const { dispatch } = createMocks(listener); + expect(dispatch.mock.calls.length).toBe(0); + + listener.trigger('online'); expect(dispatch.mock.calls.length).toBe(1); jest.advanceTimersByTime(5000); expect(dispatch.mock.calls.length).toBe(2); + expect(listener.offlineHandlers.length).toBe(1); + expect(listener.onlineHandlers.length).toBe(0); }); it('should stop dispatching when offline again', () => { - dispatch.mockReset(); - triggerEvent('offline', new Event('offline')); + const listener = new MockConnectionListener(true); + const { dispatch } = createMocks(listener); + expect(dispatch.mock.calls.length).toBe(1); + + listener.trigger('offline'); jest.advanceTimersByTime(50000); - expect(dispatch.mock.calls.length).toBe(0); + expect(dispatch.mock.calls.length).toBe(1); + expect(listener.offlineHandlers.length).toBe(0); + expect(listener.onlineHandlers.length).toBe(1); }); }); }); diff --git a/packages/rest-hooks/src/state/isOnline.ts b/packages/rest-hooks/src/state/isOnline.ts deleted file mode 100644 index 385d706cec5..00000000000 --- a/packages/rest-hooks/src/state/isOnline.ts +++ /dev/null @@ -1,6 +0,0 @@ -export default function isOnline(): boolean { - if (navigator.onLine !== undefined) { - return navigator.onLine; - } - return true; -}