Skip to content

Commit ae11a4d

Browse files
committed
fix: handle localStorage being null (Firefox dom.storage.enabled: false)
Use undefined as initial sentinel for storageItem ref instead of null, so the first render parsing branch always runs. Replace goodTry with try-catch in default-value writing to fall back to inMemoryData when localStorage is unavailable. Fixes #80
1 parent 36eb0c0 commit ae11a4d

File tree

2 files changed

+118
-6
lines changed

2 files changed

+118
-6
lines changed

src/useLocalStorageState.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,8 @@ function useLocalStorage<T>(
6363
stringify: (value: unknown) => string = JSON.stringify,
6464
): LocalStorageState<T | undefined> {
6565
// we keep the `parsed` value in a ref because `useSyncExternalStore` requires a cached version
66-
const storageItem = useRef<{ string: string | null; parsed: T | undefined }>({
67-
string: null,
66+
const storageItem = useRef<{ string: string | null | undefined; parsed: T | undefined }>({
67+
string: undefined,
6868
parsed: undefined,
6969
})
7070

@@ -110,19 +110,21 @@ function useLocalStorage<T>(
110110
// issues that were caused by incorrect initial and secondary implementations:
111111
// - https://github.com/astoilkov/use-local-storage-state/issues/30
112112
// - https://github.com/astoilkov/use-local-storage-state/issues/33
113-
if (defaultValue !== undefined && string === null) {
113+
if (defaultValue !== undefined && string === null && !inMemoryData.has(key)) {
114114
// reasons for `localStorage` to throw an error:
115115
// - maximum quota is exceeded
116116
// - under Mobile Safari (since iOS 5) when the user enters private mode
117117
// `localStorage.setItem()` will throw
118118
// - trying to access localStorage object when cookies are disabled in Safari throws
119119
// "SecurityError: The operation is insecure."
120-
// eslint-disable-next-line no-console
121-
goodTry(() => {
120+
// - localStorage is `null` in Firefox when `dom.storage.enabled` is `false`
121+
try {
122122
const string = stringify(defaultValue)
123123
localStorage.setItem(key, string)
124124
storageItem.current = { string, parsed: defaultValue }
125-
})
125+
} catch {
126+
inMemoryData.set(key, defaultValue)
127+
}
126128
}
127129

128130
return storageItem.current.parsed
@@ -159,6 +161,11 @@ function useLocalStorage<T>(
159161

160162
inMemoryData.delete(key)
161163

164+
// reset the sentinel so getSnapshot re-parses the value (important when localStorage
165+
// is unavailable and the string stays `null` — without this, `null !== null` would be
166+
// false and the parsing branch would be skipped)
167+
storageItem.current.string = undefined
168+
162169
triggerCallbacks(key)
163170
}, [key])
164171

test/browser.test.tsx

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -705,6 +705,111 @@ describe('useLocalStorageState()', () => {
705705
})
706706
})
707707

708+
describe('localStorage is null (Firefox with dom.storage.enabled: false)', () => {
709+
test('returns defaultValue when localStorage is null', () => {
710+
vi.spyOn(window, 'localStorage', 'get').mockReturnValue(null as any)
711+
712+
const { result } = renderHook(() =>
713+
useLocalStorageState('todos', { defaultValue: ['first', 'second'] }),
714+
)
715+
716+
const [todos] = result.current
717+
expect(todos).toStrictEqual(['first', 'second'])
718+
})
719+
720+
test('isPersistent is true when value equals defaultValue and localStorage is null', () => {
721+
vi.spyOn(window, 'localStorage', 'get').mockReturnValue(null as any)
722+
723+
const { result } = renderHook(() =>
724+
useLocalStorageState('todos', { defaultValue: ['first', 'second'] }),
725+
)
726+
727+
const [, , { isPersistent }] = result.current
728+
expect(isPersistent).toBe(true)
729+
})
730+
731+
test('isPersistent is false when value is changed and localStorage is null', () => {
732+
vi.spyOn(window, 'localStorage', 'get').mockReturnValue(null as any)
733+
734+
const { result } = renderHook(() =>
735+
useLocalStorageState('todos', { defaultValue: ['first', 'second'] }),
736+
)
737+
738+
act(() => {
739+
const setTodos = result.current[1]
740+
setTodos(['third', 'forth'])
741+
})
742+
743+
const [todos, , { isPersistent }] = result.current
744+
expect(todos).toStrictEqual(['third', 'forth'])
745+
expect(isPersistent).toBe(false)
746+
})
747+
748+
test('setValue works when localStorage is null', () => {
749+
vi.spyOn(window, 'localStorage', 'get').mockReturnValue(null as any)
750+
751+
const { result } = renderHook(() =>
752+
useLocalStorageState('todos', { defaultValue: ['first', 'second'] }),
753+
)
754+
755+
act(() => {
756+
const setTodos = result.current[1]
757+
setTodos(['third', 'forth'])
758+
})
759+
760+
const [todos] = result.current
761+
expect(todos).toStrictEqual(['third', 'forth'])
762+
})
763+
764+
test('setValue with callback works when localStorage is null', () => {
765+
vi.spyOn(window, 'localStorage', 'get').mockReturnValue(null as any)
766+
767+
const { result } = renderHook(() =>
768+
useLocalStorageState('todos', { defaultValue: ['first', 'second'] }),
769+
)
770+
771+
act(() => {
772+
const setTodos = result.current[1]
773+
setTodos((value) => [...value, 'third'])
774+
})
775+
776+
const [todos] = result.current
777+
expect(todos).toStrictEqual(['first', 'second', 'third'])
778+
})
779+
780+
test('removeItem works when localStorage is null', () => {
781+
vi.spyOn(window, 'localStorage', 'get').mockReturnValue(null as any)
782+
783+
const { result } = renderHook(() =>
784+
useLocalStorageState('todos', { defaultValue: ['first', 'second'] }),
785+
)
786+
787+
act(() => {
788+
const setTodos = result.current[1]
789+
setTodos(['third', 'forth'])
790+
})
791+
792+
act(() => {
793+
const removeItem = result.current[2].removeItem
794+
removeItem()
795+
})
796+
797+
const [todos] = result.current
798+
expect(todos).toStrictEqual(['first', 'second'])
799+
})
800+
801+
test('returns undefined when no defaultValue and localStorage is null', () => {
802+
vi.spyOn(window, 'localStorage', 'get').mockReturnValue(null as any)
803+
804+
const { result } = renderHook(() =>
805+
useLocalStorageState('todos'),
806+
)
807+
808+
const [todos] = result.current
809+
expect(todos).toBe(undefined)
810+
})
811+
})
812+
708813
describe('"serializer" option', () => {
709814
test('can serialize Date from initial value', () => {
710815
const date = new Date()

0 commit comments

Comments
 (0)