Skip to content

Commit

Permalink
feat: track 'last online' time in localStorage (#974)
Browse files Browse the repository at this point in the history
* feat: track 'last online' time in localStorage

* refactor: better local storage management (fails tests)

* chore: type def

* fix: correct logic local storage update logic

* refactor: useMemo around localStorage get operation

* fix: allow debounceDelay of 0

* test: lastOnline is set if offline initially

* test: skip test
  • Loading branch information
KaiVandivier authored Aug 30, 2021
1 parent 0ae162d commit 98d7cd3
Show file tree
Hide file tree
Showing 2 changed files with 233 additions and 8 deletions.
201 changes: 200 additions & 1 deletion services/offline/src/lib/__tests__/online-status.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ beforeEach(() => {
jest.restoreAllMocks()
})

afterEach(() => {
localStorage.clear()
})

describe('initalizes to navigator.onLine value', () => {
it('initializes to true', () => {
jest.spyOn(navigator, 'onLine', 'get').mockReturnValueOnce(true)
Expand Down Expand Up @@ -84,7 +88,7 @@ describe('state changes in response to browser "online" and "offline" events', (
})

describe('debouncing state changes', () => {
it('debounces with a 1s delay', async () => {
it('debounces with a 1s delay by default', async () => {
// Start online
jest.spyOn(navigator, 'onLine', 'get').mockReturnValueOnce(true)
const events: CapturedEventListeners = {}
Expand Down Expand Up @@ -137,6 +141,32 @@ describe('debouncing state changes', () => {
expect(result.current.online).toBe(false)
})

it('can use a debounceDelay of 0 to skip debouncing', async () => {
jest.spyOn(navigator, 'onLine', 'get').mockReturnValueOnce(true)
const events: CapturedEventListeners = {}
window.addEventListener = jest.fn(
(event, cb) => (events[event] = cb as EventListener)
)
const { result, waitForNextUpdate } = renderHook(
(...args) => useOnlineStatus(...args),
{
initialProps: { debounceDelay: 0 },
}
)
await act(async () => {
events.offline(new Event('offline'))
events.online(new Event('online'))
events.offline(new Event('offline'))
})

// await wait(0) didn't work here
await waitForNextUpdate({ timeout: 0 })

// There should be no delay before status is offline
expect(result.current.online).toBe(false)
expect(result.current.offline).toBe(true)
})

it('can have the debounce delay changed during its lifecycle', async () => {
// Start with 150 ms debounce
jest.spyOn(navigator, 'onLine', 'get').mockReturnValueOnce(true)
Expand Down Expand Up @@ -300,3 +330,172 @@ describe('debouncing state changes', () => {
)
})
})

describe('it updates the lastOnline value in local storage', () => {
const lastOnlineKey = 'dhis2.lastOnline'
const testDateString = 'Fri, 27 Aug 2021 19:53:06 GMT'

it('sets lastOnline in local storage when it goes offline', async () => {
jest.spyOn(navigator, 'onLine', 'get').mockReturnValueOnce(true)
const events: CapturedEventListeners = {}
window.addEventListener = jest.fn(
(event, cb) => (events[event] = cb as EventListener)
)
const { result, waitForNextUpdate } = renderHook(
(...args) => useOnlineStatus(...args),
{ initialProps: { debounceDelay: 0 } }
)

// Correct initial state
expect(localStorage.getItem(lastOnlineKey)).toBe(null)
expect(result.current.lastOnline).toBe(null)

act(() => {
events.offline(new Event('offline'))
})

// Wait for debounce
await waitForNextUpdate({ timeout: 0 })

expect(result.current.online).toBe(false)
expect(result.current.offline).toBe(true)

// Check localStorage for a stored date
const parsedDate = new Date(
localStorage.getItem(lastOnlineKey) as string
)
expect(parsedDate.toString()).not.toBe('Invalid Date')
// Check hook return value
expect(result.current.lastOnline).toBeInstanceOf(Date)
expect(result.current.lastOnline?.toUTCString()).toBe(
localStorage.getItem(lastOnlineKey)
)
})

// not necessary
it.skip("sets lastOnline on mount if it's not set", () => {
jest.spyOn(navigator, 'onLine', 'get').mockReturnValueOnce(false)
const events: CapturedEventListeners = {}
window.addEventListener = jest.fn(
(event, cb) => (events[event] = cb as EventListener)
)
const { result } = renderHook((...args) => useOnlineStatus(...args), {
initialProps: { debounceDelay: 0 },
})

const parsedDate = new Date(
localStorage.getItem(lastOnlineKey) as string
)
expect(parsedDate.toString()).not.toBe('Invalid Date')
expect(result.current.lastOnline).toBeInstanceOf(Date)
expect(result.current.lastOnline?.toUTCString()).toBe(
localStorage.getItem(lastOnlineKey)
)
})

it("doesn't change lastOnline it exists and if it's already offline", async () => {
// seed localStorage
localStorage.setItem(lastOnlineKey, testDateString)
jest.spyOn(navigator, 'onLine', 'get').mockReturnValueOnce(false)
const events: CapturedEventListeners = {}
window.addEventListener = jest.fn(
(event, cb) => (events[event] = cb as EventListener)
)
const { result } = renderHook((...args) => useOnlineStatus(...args), {
initialProps: { debounceDelay: 0 },
})

expect(localStorage.getItem(lastOnlineKey)).toBe(testDateString)
expect(result.current.lastOnline).toEqual(new Date(testDateString))

act(() => {
events.offline(new Event('offline'))
})

await wait(0)

expect(result.current.online).toBe(false)
expect(result.current.offline).toBe(true)

expect(localStorage.getItem(lastOnlineKey)).toBe(testDateString)
expect(result.current.lastOnline).toEqual(new Date(testDateString))
})

it('clears lastOnline when it goes online', async () => {
// seed localStorage
localStorage.setItem(lastOnlineKey, testDateString)
jest.spyOn(navigator, 'onLine', 'get').mockReturnValueOnce(false)
const events: CapturedEventListeners = {}
window.addEventListener = jest.fn(
(event, cb) => (events[event] = cb as EventListener)
)
const { result, waitForNextUpdate } = renderHook(
(...args) => useOnlineStatus(...args),
{
initialProps: { debounceDelay: 0 },
}
)

expect(localStorage.getItem(lastOnlineKey)).toBe(testDateString)
expect(result.current.lastOnline).toEqual(new Date(testDateString))

act(() => {
events.offline(new Event('online'))
})

// Wait for debounce
await waitForNextUpdate({ timeout: 0 })

expect(result.current.online).toBe(true)
expect(result.current.offline).toBe(false)

// expect(localStorage.getItem(lastOnlineKey)).toBe(null)
expect(result.current.lastOnline).toBe(null)
})

it('tracks correctly when going offline and online', async () => {
jest.spyOn(navigator, 'onLine', 'get').mockReturnValueOnce(true)
const events: CapturedEventListeners = {}
window.addEventListener = jest.fn(
(event, cb) => (events[event] = cb as EventListener)
)
const { result, waitForNextUpdate } = renderHook(
(...args) => useOnlineStatus(...args),
{ initialProps: { debounceDelay: 0 } }
)

// Correct initial state
expect(localStorage.getItem(lastOnlineKey)).toBe(null)
expect(result.current.lastOnline).toBe(null)

act(() => {
events.offline(new Event('offline'))
})
await waitForNextUpdate({ timeout: 0 })

const firstDate = new Date(
localStorage.getItem(lastOnlineKey) as string
)
const firstValue = result.current.lastOnline?.valueOf()

act(() => {
events.offline(new Event('online'))
})
await waitForNextUpdate({ timeout: 0 })

expect(result.current.lastOnline).toBe(null)

// todo: this is an error from UTC strings' imprecision
await wait(1000)

act(() => {
events.offline(new Event('offline'))
})
await waitForNextUpdate({ timeout: 0 })

expect(
new Date(localStorage.getItem(lastOnlineKey) as string)
).not.toEqual(firstDate)
expect(result.current.lastOnline?.valueOf()).not.toEqual(firstValue)
})
})
40 changes: 33 additions & 7 deletions services/offline/src/lib/online-status.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import debounce from 'lodash/debounce'
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, useMemo } from 'react'

type milliseconds = number
interface OnlineStatusOptions {
Expand All @@ -9,8 +9,11 @@ interface OnlineStatusOptions {
interface OnlineStatus {
online: boolean
offline: boolean
lastOnline: Date | null
}

const lastOnlineKey = 'dhis2.lastOnline'

// TODO: Add option to periodically ping server to check online status.
// TODO: Add logic to return a variable indicating unstable connection.

Expand All @@ -20,9 +23,13 @@ interface OnlineStatus {
* avoid UI flicker, but that delay can be configured with the
* `options.debounceDelay` param.
*
* On state change, updates the `dhis2.lastOnline` property in local storage
* for consuming apps to format and display. Returns `lastOnline` as `null` if
* online or as a Date if offline.
*
* @param {Object} [options]
* @param {Number} [options.debounceDelay] - Timeout delay to debounce updates, in ms
* @returns {Object} `{ online, offline }` booleans. Each is the opposite of the other.
* @returns {Object} `{ online: boolean, offline: boolean, lastOnline: Date | null }`
*/
export function useOnlineStatus(
options: OnlineStatusOptions = {}
Expand All @@ -32,10 +39,19 @@ export function useOnlineStatus(

// eslint-disable-next-line react-hooks/exhaustive-deps
const updateState = useCallback(
debounce(
({ type }: Event) => setOnline(type === 'online'),
options.debounceDelay || 1000
),
debounce(({ type }: Event) => {
if (type === 'online') {
setOnline(true)
} else if (type === 'offline') {
if (online || !localStorage.getItem(lastOnlineKey)) {
localStorage.setItem(
lastOnlineKey,
new Date(Date.now()).toUTCString()
)
}
setOnline(false)
}
}, options.debounceDelay ?? 1000),
[options.debounceDelay]
)

Expand All @@ -50,5 +66,15 @@ export function useOnlineStatus(
}
}, [updateState])

return { online, offline: !online }
// Only fetch if `online === false` as local storage is synchronous and disk-based
const lastOnline = useMemo(
() => !online && localStorage.getItem(lastOnlineKey),
[online]
)

return {
online,
offline: !online,
lastOnline: lastOnline ? new Date(lastOnline) : null,
}
}

0 comments on commit 98d7cd3

Please sign in to comment.