Skip to content

Commit

Permalink
♻️ ability to specify optional provider for sessionStorage
Browse files Browse the repository at this point in the history
  • Loading branch information
Luis Merino committed Nov 11, 2020
1 parent 47c165f commit 7402a2a
Show file tree
Hide file tree
Showing 4 changed files with 111 additions and 47 deletions.
Binary file added .DS_Store
Binary file not shown.
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"typescript.tsdk": "node_modules/typescript/lib"
}
80 changes: 51 additions & 29 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,44 +13,58 @@ function parseJSON(value: string | null) {
}

/**
* Abstraction for localStorage that uses an in-memory fallback when localStorage throws an error.
* Abstraction for web storage that uses an in-memory fallback when localStorage/sessionStorage throws an error.
* Reasons for throwing an error:
* - maximum quota is exceeded
* - under Mobile Safari (since iOS 5) when the user enters private mode `localStorage.setItem()`
* will throw
* - trying to access localStorage object when cookies are disabled in Safari throws
* "SecurityError: The operation is insecure."
*/
const data: Record<string, unknown> = {}
const storage = {
get<T>(key: string, defaultValue: T | (() => T)): T {
try {
return data[key] ?? parseJSON(localStorage.getItem(key))
} catch {
return unwrapValue(defaultValue)
}
},
set<T>(key: string, value: T): boolean {
try {
localStorage.setItem(key, JSON.stringify(value))
interface MemoryStorage {
get<T>(key: string, defaultValue: T | (() => T)): T
set<T>(key: string, value: T): boolean
remove(key: string): void
}

const createStorage = (provider: Storage): MemoryStorage => {
const data: Record<string, unknown> = {}

return {
get<T>(key: string, defaultValue: T | (() => T)): T {
try {
return data[key] ?? parseJSON(provider.getItem(key))
} catch {
return unwrapValue(defaultValue)
}
},
set<T>(key: string, value: T): boolean {
try {
provider.setItem(key, JSON.stringify(value))

data[key] = undefined

return true
} catch {
data[key] = value
return false
}
},
remove(key: string): void {
data[key] = undefined

return true
} catch {
data[key] = value
return false
}
},
remove(key: string): void {
data[key] = undefined

try {
localStorage.removeItem(key)
} catch {}
},
try {
provider.removeItem(key)
} catch {}
},
}
}

const storages = new Map([
[localStorage, createStorage(localStorage)],
[sessionStorage, createStorage(sessionStorage)],
])

/**
* Used to track usages of `useLocalStorageState()` with identical `key` values. If we encounter
* duplicates we throw an error to the user telling them to use `createLocalStorageStateHook`
Expand All @@ -71,17 +85,21 @@ type UpdateState<T> = {

export default function useLocalStorageState<T = undefined>(
key: string,
provider?: Storage,
): [T | undefined, UpdateState<T | undefined>, boolean]
export default function useLocalStorageState<T>(
key: string,
defaultValue: T | (() => T),
provider?: Storage,
): [T, UpdateState<T>, boolean]
export default function useLocalStorageState<T = undefined>(
key: string,
defaultValue?: T | (() => T),
provider: Storage = localStorage,
): [T | undefined, UpdateState<T | undefined>, boolean] {
// we don't support updating the `defaultValue` the same way `useState()` doesn't support it
const defaultValueRef = useRef(defaultValue)
const storage = storages.get(provider) as MemoryStorage
const [state, setState] = useState(() => {
return {
value: storage.get(key, defaultValueRef.current),
Expand All @@ -95,8 +113,8 @@ export default function useLocalStorageState<T = undefined>(
return true
}
try {
localStorage.setItem('--use-local-storage-state--', 'dummy')
localStorage.removeItem('--use-local-storage-state--')
provider.setItem('--use-web-storage-state--', 'dummy')
provider.removeItem('--use-web-storage-state--')
return true
} catch {
return false
Expand Down Expand Up @@ -152,7 +170,7 @@ export default function useLocalStorageState<T = undefined>(
*/
useEffect(() => {
const onStorage = (e: StorageEvent): void => {
if (e.storageArea === localStorage && e.key === key) {
if (e.storageArea === provider && e.key === key) {
setState({
value: storage.get(key, defaultValueRef.current),
isPersistent: false,
Expand All @@ -170,14 +188,17 @@ export default function useLocalStorageState<T = undefined>(

export function createLocalStorageStateHook<T = undefined>(
key: string,
provider?: Storage,
): () => [T | undefined, UpdateState<T | undefined>, boolean]
export function createLocalStorageStateHook<T>(
key: string,
defaultValue: T | (() => T),
provider?: Storage,
): () => [T, UpdateState<T>, boolean]
export function createLocalStorageStateHook<T>(
key: string,
defaultValue?: T | (() => T),
provider: Storage = localStorage,
): () => [T | undefined, UpdateState<T | undefined>, boolean] {
const updates: UpdateState<T | undefined>[] = []
return function useLocalStorageStateHook(): [
Expand All @@ -188,6 +209,7 @@ export function createLocalStorageStateHook<T>(
const [value, setValue, isPersistent] = useLocalStorageState<T | undefined>(
key,
defaultValue,
provider,
)
const updateValue = useMemo(() => {
const fn = (newValue: SetStateParameter<T>) => {
Expand Down
75 changes: 57 additions & 18 deletions test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import useLocalStorageState, { createLocalStorageStateHook } from '.'

beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
})

describe('useLocalStorageState()', () => {
Expand Down Expand Up @@ -77,7 +78,21 @@ describe('useLocalStorageState()', () => {
expect(localStorage.getItem('todos')).toEqual(JSON.stringify(['third', 'forth']))
})

it('storage event updates state', () => {
it('update writes into sessionStorage', () => {
const { result } = renderHook(() =>
useLocalStorageState('todos', ['first', 'second'], sessionStorage),
)

act(() => {
const setTodos = result.current[1]

setTodos(['third', 'forth'])
})

expect(sessionStorage.getItem('todos')).toEqual(JSON.stringify(['third', 'forth']))
})

it('storage event updates state with localStorage', () => {
const { result } = renderHook(() => useLocalStorageState('todos', ['first', 'second']))

/**
Expand All @@ -99,6 +114,30 @@ describe('useLocalStorageState()', () => {
expect(todosA).toEqual(['third', 'forth'])
})

it('storage event updates state with sessionStorage', () => {
const { result } = renderHook(() =>
useLocalStorageState('todos', ['first', 'second'], sessionStorage),
)

/**
* #WET 2020-03-19T8:55:25+02:00
*/
act(() => {
sessionStorage.setItem('todos', JSON.stringify(['third', 'forth']))
window.dispatchEvent(
new StorageEvent('storage', {
storageArea: sessionStorage,
key: 'todos',
oldValue: JSON.stringify(['first', 'second']),
newValue: JSON.stringify(['third', 'forth']),
}),
)
})

const [todosA] = result.current
expect(todosA).toEqual(['third', 'forth'])
})

it('storage event updates state to default value', () => {
const { result } = renderHook(() => useLocalStorageState('todos', ['first', 'second']))

Expand Down Expand Up @@ -266,7 +305,7 @@ describe('useLocalStorageState()', () => {
})

function Component() {
const [,, isPersistent] = useLocalStorageState('todos', ['first', 'second'])
const [, , isPersistent] = useLocalStorageState('todos', ['first', 'second'])
expect(isPersistent).toBe(true)
return null
}
Expand All @@ -277,85 +316,85 @@ describe('useLocalStorageState()', () => {

it('can set value to `undefined`', () => {
const { result: resultA, unmount } = renderHook(() =>
useLocalStorageState<string[] | undefined>('todos', ['first', 'second'])
useLocalStorageState<string[] | undefined>('todos', ['first', 'second']),
)
act(() => {
const [,setValue] = resultA.current
const [, setValue] = resultA.current
setValue(undefined)
})
unmount()

const { result: resultB } = renderHook(() =>
useLocalStorageState<string[] | undefined>('todos', ['first', 'second'])
useLocalStorageState<string[] | undefined>('todos', ['first', 'second']),
)
const [value] = resultB.current
expect(value).toBe(undefined)
})

it('can set value to `null`', () => {
const { result: resultA, unmount } = renderHook(() =>
useLocalStorageState<string[] | null>('todos', ['first', 'second'])
useLocalStorageState<string[] | null>('todos', ['first', 'second']),
)
act(() => {
const [,setValue] = resultA.current
const [, setValue] = resultA.current
setValue(null)
})
unmount()

const { result: resultB } = renderHook(() =>
useLocalStorageState<string[] | null>('todos', ['first', 'second'])
useLocalStorageState<string[] | null>('todos', ['first', 'second']),
)
const [value] = resultB.current
expect(value).toBe(null)
})

it('can reset value to default', () => {
const { result: resultA, unmount: unmountA } = renderHook(() =>
useLocalStorageState<string[]>('todos', ['first', 'second'])
useLocalStorageState<string[]>('todos', ['first', 'second']),
)
act(() => {
const [,setValue] = resultA.current
const [, setValue] = resultA.current
setValue(['third', 'forth'])
})
unmountA()

const { result: resultB, unmount: unmountB } = renderHook(() =>
useLocalStorageState<string[]>('todos', ['first', 'second'])
useLocalStorageState<string[]>('todos', ['first', 'second']),
)
act(() => {
const [,setValue] = resultB.current
const [, setValue] = resultB.current
setValue.reset()
})
unmountB()

const { result: resultC } = renderHook(() =>
useLocalStorageState<string[]>('todos', ['first', 'second'])
useLocalStorageState<string[]>('todos', ['first', 'second']),
)
const [value] = resultC.current
expect(value).toEqual(['first', 'second'])
})

it('can reset value to default (default with callback function)', () => {
const { result: resultA, unmount: unmountA } = renderHook(() =>
useLocalStorageState<string[]>('todos', () => ['first', 'second'])
useLocalStorageState<string[]>('todos', () => ['first', 'second']),
)
act(() => {
const [,setValue] = resultA.current
const [, setValue] = resultA.current
setValue(['third', 'forth'])
})
unmountA()

const { result: resultB, unmount: unmountB } = renderHook(() =>
useLocalStorageState<string[]>('todos', () => ['first', 'second'])
useLocalStorageState<string[]>('todos', () => ['first', 'second']),
)
act(() => {
const [,setValue] = resultB.current
const [, setValue] = resultB.current
setValue.reset()
})
unmountB()

const { result: resultC } = renderHook(() =>
useLocalStorageState<string[]>('todos', ['first', 'second'])
useLocalStorageState<string[]>('todos', ['first', 'second']),
)
const [value] = resultC.current
expect(value).toEqual(['first', 'second'])
Expand Down

0 comments on commit 7402a2a

Please sign in to comment.