Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(popup): add useLocalStorage hook #419

Merged
merged 4 commits into from
Jul 15, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions src/popup/lib/hooks.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import React from 'react'
import { fireEvent, render } from '@testing-library/react'
import { useLocalStorage } from './hooks'

describe('useLocalStorage', () => {
const defaultMaxAge = 1000 * 24 * 60 * 60
let now = Date.now()
let defaultExpiresAt = now + defaultMaxAge
beforeAll(() => {
jest.useFakeTimers()
})
beforeEach(() => {
localStorage.clear()
now = jest.getRealSystemTime()
defaultExpiresAt = now + defaultMaxAge
})
afterAll(() => {
jest.useRealTimers()
localStorage.clear()
})

function TestComponent({ maxAge = defaultMaxAge }: { maxAge?: number }) {
const [data, setData, clear] = useLocalStorage('name', 'John Doe', {
maxAge
})
return (
<>
<p data-testid="data">{data}</p>
<button data-testid="set" onClick={() => setData('John Wick')}>
Set data
</button>
<button
data-testid="set-cb"
onClick={() => setData((data) => data + 'Foo')}
>
Set data callback
</button>
<button data-testid="clear" onClick={() => clear()}>
Clear data
</button>
</>
)
}

it('does no set localStorage based on default value', () => {
const { getByTestId } = render(<TestComponent />)
expect(localStorage.getItem('name')).toBeNull()
expect(getByTestId('data')).toHaveTextContent('John')
})
sidvishnoi marked this conversation as resolved.
Show resolved Hide resolved

it('gets localStorage value instead of default', () => {
localStorage.setItem(
'name',
JSON.stringify({ value: 'Johnny', expiresAt: defaultExpiresAt })
)
const { getByTestId } = render(<TestComponent />)
expect(getByTestId('data')).toHaveTextContent('Johnny')
})

it('changes localStorage and state value', () => {
localStorage.setItem(
'name',
JSON.stringify({ value: 'Johnny', expiresAt: defaultExpiresAt })
)
const { getByTestId } = render(<TestComponent />)

fireEvent.click(getByTestId('set'))
expect(getByTestId('data')).toHaveTextContent('John Wick')

expect(localStorage.getItem('name')).not.toBeNull()
const stored = JSON.parse(localStorage.getItem('name')!)
expect(stored.value).toBe('John Wick')
expect(stored.expiresAt).toBeGreaterThan(defaultExpiresAt)
})

it('changes localStorage and state value using callback', () => {
localStorage.setItem(
'name',
JSON.stringify({ value: 'Johnny', expiresAt: defaultExpiresAt })
)
const { getByTestId } = render(<TestComponent />)

fireEvent.click(getByTestId('set-cb'))
expect(getByTestId('data')).toHaveTextContent('JohnnyFoo')

expect(localStorage.getItem('name')).not.toBeNull()
const stored = JSON.parse(localStorage.getItem('name')!)
expect(stored.value).toBe('JohnnyFoo')
expect(stored.expiresAt).toBeGreaterThan(defaultExpiresAt)
})

it('should remove item from localStorage when clear is called', () => {
const { getByTestId } = render(<TestComponent />)

fireEvent.click(getByTestId('clear'))
expect(getByTestId('data')).toHaveTextContent('John')
expect(localStorage.getItem('name')).toBeNull()
})

it('should be able to set value again after it was removed from localStorage', () => {
const { getByTestId } = render(<TestComponent />)

fireEvent.click(getByTestId('clear'))
fireEvent.click(getByTestId('set'))

expect(localStorage.getItem('name')).not.toBeNull()
const stored = JSON.parse(localStorage.getItem('name')!)
expect(stored.value).toBe('John Wick')
expect(stored.expiresAt).toBeGreaterThan(defaultExpiresAt)
})

it('should respect maxAge', () => {
const maxAge = 5
const ui = <TestComponent maxAge={maxAge} />
const { getByTestId, unmount } = render(ui)
expect(getByTestId('data')).toHaveTextContent('John')
expect(localStorage.getItem('name')).toBeNull()

fireEvent.click(getByTestId('set'))
const now = Date.now()
expect(getByTestId('data')).toHaveTextContent('John Wick')
expect(localStorage.getItem('name')).not.toBeNull()
const stored = JSON.parse(localStorage.getItem('name')!)
expect(stored.value).toBe('John Wick')
expect(stored.expiresAt).toBeGreaterThanOrEqual(now + maxAge * 1_000)
expect(stored.expiresAt).toBeLessThan(defaultExpiresAt)

jest.setSystemTime(now + (maxAge + 1) * 1_000)
jest.advanceTimersByTime(now + (maxAge + 1) * 1_000)

unmount()
const remounted = render(ui)
expect(remounted.getByTestId('data')).toHaveTextContent('John Doe')
expect(localStorage.getItem('name')).toBeNull()
})
})
62 changes: 62 additions & 0 deletions src/popup/lib/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import React from 'react'

/**
* Store data in browser's local storage. Helpful in retrieving state after
* popup closes.
*
* Can set a `maxAge` (in seconds, default 1000 days - AKA forever but not
* Infinity) to avoid using state data. Stale data is cleared on access only.
sidvishnoi marked this conversation as resolved.
Show resolved Hide resolved
*
* @note Don't call it too often to avoid performance issues, as it's
* synchronous and calls JSON.stringify and JSON.parse APIs.
*/
export function useLocalStorage<T>(
key: string,
defaultValue: T,
{ maxAge = 1000 * 24 * 60 * 60 }: Partial<{ maxAge: number }> = {}
) {
const hasLocalStorage = typeof localStorage !== 'undefined'
maxAge *= 1000

type Stored = { value: T; expiresAt: number }
const isWellFormed = React.useCallback((obj: any): obj is Stored => {
if (typeof obj !== 'object' || obj == null) return false
if (!obj.expiresAt || !Number.isSafeInteger(obj.expiresAt)) return false
return typeof obj.value !== 'undefined'
}, [])

const [value, setValue] = React.useState<T>(() => {
if (!hasLocalStorage) return defaultValue

const storedValue = localStorage.getItem(key)
if (!storedValue) return defaultValue

try {
const data = JSON.parse(storedValue)
if (isWellFormed(data) && data.expiresAt > Date.now()) {
return data.value
} else {
localStorage.removeItem(key)
}
} catch {
// do nothing
}
return defaultValue
})

React.useEffect(() => {
if (hasLocalStorage && value !== defaultValue) {
const expiresAt = Date.now() + maxAge
const data: Stored = { value, expiresAt }
localStorage.setItem(key, JSON.stringify(data))
}
}, [value, key, defaultValue, maxAge, hasLocalStorage])

const clearStorage = () => {
if (hasLocalStorage) {
localStorage.removeItem(key)
}
}

return [value, setValue, clearStorage] as const
}
Loading