Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
25 changes: 21 additions & 4 deletions src/useLocalStorageState.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable no-console */
import type { Dispatch, SetStateAction } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'

Expand All @@ -12,6 +13,7 @@ export type LocalStorageOptions<T> = {
stringify: (value: unknown) => string
parse: (value: string) => unknown
}
onError?: (err: unknown, oldValue: T | undefined, newValue: string) => T | undefined
}

// - `useLocalStorageState()` return type
Expand Down Expand Up @@ -44,10 +46,20 @@ export default function useLocalStorageState<T = undefined>(
const serializer = options?.serializer
const [defaultValue] = useState(options?.defaultValue)
const [defaultServerValue] = useState(options?.defaultServerValue)

const defaultOnError = (
err: unknown,
): T | undefined => {
// eslint-disable-next-line no-console
console.error(err)
return defaultValue
}

return useLocalStorage(
key,
defaultValue,
defaultServerValue,
options?.onError ?? defaultOnError,
options?.storageSync,
serializer?.parse,
serializer?.stringify,
Expand All @@ -58,6 +70,7 @@ function useLocalStorage<T>(
key: string,
defaultValue: T | undefined,
defaultServerValue: T | undefined,
onError: Required<LocalStorageOptions<T>>['onError'],
storageSync: boolean = true,
parse: (value: string) => unknown = parseJSON,
stringify: (value: unknown) => string = JSON.stringify,
Expand Down Expand Up @@ -94,12 +107,15 @@ function useLocalStorage<T>(
} else if (string !== storageItem.current.string) {
let parsed: T | undefined

try {
parsed = string === null ? defaultValue : (parse(string) as T)
} catch {
if (string === null) {
parsed = defaultValue
} else {
try {
parsed = parse(string) as T
} catch (err) {
parsed = onError(err, storageItem.current.parsed, string)
}
}

storageItem.current.parsed = parsed
}

Expand Down Expand Up @@ -171,6 +187,7 @@ function useLocalStorage<T>(
}

const onStorage = (e: StorageEvent): void => {
console.log("storage")
if (e.key === key && e.storageArea === goodTry(() => localStorage)) {
triggerCallbacks(key)
}
Expand Down
86 changes: 82 additions & 4 deletions test/browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@

import util from 'node:util'
import superjson from 'superjson'
import { act, render, renderHook } from '@testing-library/react'
import { act, render, renderHook, waitFor } from '@testing-library/react'
import React, { useEffect, useLayoutEffect, useMemo } from 'react'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import useLocalStorageState, { inMemoryData } from '../src/useLocalStorageState.js'

let originalError = console.error;

beforeEach(() => {
// Throw an error when `console.error()` is called. This is especially useful in a React tests
// because React uses it to show warnings and discourage you from shooting yourself in the foot.
Expand All @@ -25,7 +27,13 @@ beforeEach(() => {
// (`Component`). To locate the bad setState() call inside `Component`, follow the stack trace
// as described in https://reactjs.org/link/setstate-in-render"
vi.spyOn(console, 'error').mockImplementation((format: string, ...args: any[]) => {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do like this pattern.

What I've done in the past is maintained a list of allowed errors, and a second list of errors that should be printed to the console.

throw new Error(util.format(format, ...args))

// Just commenting this out, but basically add exceptions to this
//throw new Error(util.format(format, ...args))

originalError(format, ...args);


})
})

Expand All @@ -34,7 +42,7 @@ afterEach(() => {
try {
localStorage.clear()
sessionStorage.clear()
} catch {}
} catch { }
})

describe('useLocalStorageState()', () => {
Expand Down Expand Up @@ -758,8 +766,78 @@ describe('useLocalStorageState()', () => {
serializer: JSON,
}),
)
const [value] = resultB.current
const [value] = resultB.current;
expect(value).toEqual(['first', 'second'])
expect(value).not.toBe(undefined)
})
})

describe.only('"onError" option', () => {

// I really want to be able to write a test like this!
// But it's not working? JSDOM has implemented the storageEvent thought:
// https://github.com/jsdom/jsdom/pull/2076
test.skip("sanity test", async () => {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nb. 👈

const renderResult = renderHook(() =>
useLocalStorageState<string>('todos', {
defaultValue: "hello world",
serializer: {
stringify: (v) => v as string,
parse: v => v
}

}));

expect(renderResult.result.current[0]).toBe("hello world");

localStorage.setItem("todos", "123");
await waitFor(() => {
return expect(renderResult.result.current[0]).toBe("123");

}); })

test("default behaviour is that if a parsing error occurs, it console.errors", async () => {

localStorage.setItem("some-string", "xyz")
const { result: resultA, unmount } = renderHook(() =>
useLocalStorageState<string>('some-string', {
defaultValue: "abc",
}),
)

expect(resultA.current[0]).toBe("abc")

const calls = vi.mocked(console.error).mock.calls;
expect(calls).toHaveLength(1);
expect(calls[0][0]).toBeInstanceOf(Error);
expect((calls[0][0] as Error).message).toBe("Unexpected token 'x', \"xyz\" is not valid JSON");
})

test("custom onError logic can be provided", async () => {

localStorage.setItem("some-string", "xyz")

const mockFn = vi.fn().mockReturnValue("error fallback");
const { result: resultA, unmount } = renderHook(() =>
useLocalStorageState<string>('some-string', {
defaultValue: "abc",
onError: mockFn

}),
)

expect(resultA.current[0]).toBe("error fallback")

const calls = vi.mocked(mockFn).mock.calls;
expect(calls).toHaveLength(1);
expect(calls[0][0]).toBeInstanceOf(Error);
expect((calls[0][0] as Error).message).toBe("Unexpected token 'x', \"xyz\" is not valid JSON");
expect(calls[0][1]).toBe(undefined);
expect(calls[0][2]).toBe("xyz")

expect(console.error).not.toHaveBeenCalled();

})
});

})