diff --git a/src/useLocalStorageState.ts b/src/useLocalStorageState.ts index 96723b9..fa849b1 100644 --- a/src/useLocalStorageState.ts +++ b/src/useLocalStorageState.ts @@ -63,8 +63,8 @@ function useLocalStorage( stringify: (value: unknown) => string = JSON.stringify, ): LocalStorageState { // we keep the `parsed` value in a ref because `useSyncExternalStore` requires a cached version - const storageItem = useRef<{ string: string | null; parsed: T | undefined }>({ - string: null, + const storageItem = useRef<{ string: string | null | undefined; parsed: T | undefined }>({ + string: undefined, parsed: undefined, }) @@ -110,19 +110,21 @@ function useLocalStorage( // issues that were caused by incorrect initial and secondary implementations: // - https://github.com/astoilkov/use-local-storage-state/issues/30 // - https://github.com/astoilkov/use-local-storage-state/issues/33 - if (defaultValue !== undefined && string === null) { + if (defaultValue !== undefined && string === null && !inMemoryData.has(key)) { // reasons for `localStorage` to throw 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." - // eslint-disable-next-line no-console - goodTry(() => { + // - localStorage is `null` in Firefox when `dom.storage.enabled` is `false` + try { const string = stringify(defaultValue) localStorage.setItem(key, string) storageItem.current = { string, parsed: defaultValue } - }) + } catch { + inMemoryData.set(key, defaultValue) + } } return storageItem.current.parsed @@ -159,6 +161,11 @@ function useLocalStorage( inMemoryData.delete(key) + // reset the sentinel so getSnapshot re-parses the value (important when localStorage + // is unavailable and the string stays `null` — without this, `null !== null` would be + // false and the parsing branch would be skipped) + storageItem.current.string = undefined + triggerCallbacks(key) }, [key]) diff --git a/test/browser.test.tsx b/test/browser.test.tsx index 07503e2..ee0c183 100644 --- a/test/browser.test.tsx +++ b/test/browser.test.tsx @@ -705,6 +705,111 @@ describe('useLocalStorageState()', () => { }) }) + describe('localStorage is null (Firefox with dom.storage.enabled: false)', () => { + test('returns defaultValue when localStorage is null', () => { + vi.spyOn(window, 'localStorage', 'get').mockReturnValue(null as any) + + const { result } = renderHook(() => + useLocalStorageState('todos', { defaultValue: ['first', 'second'] }), + ) + + const [todos] = result.current + expect(todos).toStrictEqual(['first', 'second']) + }) + + test('isPersistent is true when value equals defaultValue and localStorage is null', () => { + vi.spyOn(window, 'localStorage', 'get').mockReturnValue(null as any) + + const { result } = renderHook(() => + useLocalStorageState('todos', { defaultValue: ['first', 'second'] }), + ) + + const [, , { isPersistent }] = result.current + expect(isPersistent).toBe(true) + }) + + test('isPersistent is false when value is changed and localStorage is null', () => { + vi.spyOn(window, 'localStorage', 'get').mockReturnValue(null as any) + + const { result } = renderHook(() => + useLocalStorageState('todos', { defaultValue: ['first', 'second'] }), + ) + + act(() => { + const setTodos = result.current[1] + setTodos(['third', 'forth']) + }) + + const [todos, , { isPersistent }] = result.current + expect(todos).toStrictEqual(['third', 'forth']) + expect(isPersistent).toBe(false) + }) + + test('setValue works when localStorage is null', () => { + vi.spyOn(window, 'localStorage', 'get').mockReturnValue(null as any) + + const { result } = renderHook(() => + useLocalStorageState('todos', { defaultValue: ['first', 'second'] }), + ) + + act(() => { + const setTodos = result.current[1] + setTodos(['third', 'forth']) + }) + + const [todos] = result.current + expect(todos).toStrictEqual(['third', 'forth']) + }) + + test('setValue with callback works when localStorage is null', () => { + vi.spyOn(window, 'localStorage', 'get').mockReturnValue(null as any) + + const { result } = renderHook(() => + useLocalStorageState('todos', { defaultValue: ['first', 'second'] }), + ) + + act(() => { + const setTodos = result.current[1] + setTodos((value) => [...value, 'third']) + }) + + const [todos] = result.current + expect(todos).toStrictEqual(['first', 'second', 'third']) + }) + + test('removeItem works when localStorage is null', () => { + vi.spyOn(window, 'localStorage', 'get').mockReturnValue(null as any) + + const { result } = renderHook(() => + useLocalStorageState('todos', { defaultValue: ['first', 'second'] }), + ) + + act(() => { + const setTodos = result.current[1] + setTodos(['third', 'forth']) + }) + + act(() => { + const removeItem = result.current[2].removeItem + removeItem() + }) + + const [todos] = result.current + expect(todos).toStrictEqual(['first', 'second']) + }) + + test('returns undefined when no defaultValue and localStorage is null', () => { + vi.spyOn(window, 'localStorage', 'get').mockReturnValue(null as any) + + const { result } = renderHook(() => + useLocalStorageState('todos'), + ) + + const [todos] = result.current + expect(todos).toBe(undefined) + }) + }) + describe('"serializer" option', () => { test('can serialize Date from initial value', () => { const date = new Date()