Files
sentinel/static/app/utils/useLocalStorageState.spec.tsx
2024-02-15 17:01:50 +00:00

234 lines
7.6 KiB
TypeScript

import {reactHooks, waitFor} from 'sentry-test/reactTestingLibrary';
import localStorageWrapper from 'sentry/utils/localStorage';
import {useLocalStorageState} from 'sentry/utils/useLocalStorageState';
describe('useLocalStorageState', () => {
beforeEach(() => {
localStorageWrapper.clear();
if (localStorageWrapper.length > 0) {
throw new Error('localStorage was not cleared');
}
});
it('throws if key is not a string', () => {
const results = reactHooks.renderHook(() =>
// @ts-expect-error force incorrect usage
useLocalStorageState({}, 'default value')
);
expect(results.result.error).toBeInstanceOf(TypeError);
expect(results.result.error?.message).toBe('useLocalStorage: key must be a string');
});
it('initialized with value', () => {
const {result} = reactHooks.renderHook(
(args: Parameters<typeof useLocalStorageState>) =>
useLocalStorageState(args[0], args[1]),
{initialProps: ['key', 'default value']}
);
expect(result.current[0]).toBe('default value');
});
it('initializes with init fn', () => {
const initialize = jest.fn(() => 'default value');
const {result} = reactHooks.renderHook(
(args: Parameters<typeof useLocalStorageState>) =>
useLocalStorageState(args[0], args[1]),
{initialProps: ['key', initialize]}
);
expect(initialize).toHaveBeenCalled();
expect(result.current[0]).toBe('default value');
});
it('initializes with default value', () => {
localStorageWrapper.setItem('key', JSON.stringify('initial storage value'));
const {result} = reactHooks.renderHook(
(args: Parameters<typeof useLocalStorageState>) =>
useLocalStorageState(args[0], args[1]),
{initialProps: ['key', 'default value']}
);
expect(result.current[0]).toBe('initial storage value');
});
it('sets new value', () => {
const {result} = reactHooks.renderHook(
(args: Parameters<typeof useLocalStorageState>) =>
useLocalStorageState(args[0], args[1]),
{initialProps: ['key', 'default value']}
);
reactHooks.act(() => {
result.current[1]('new value');
});
expect(result.current[0]).toBe('new value');
});
it('updates localstorage value', async () => {
const {result} = reactHooks.renderHook(
(args: Parameters<typeof useLocalStorageState>) =>
useLocalStorageState(args[0], args[1]),
{initialProps: ['key', 'default value']}
);
const spy = jest.spyOn(Storage.prototype, 'setItem');
reactHooks.act(() => {
result.current[1]('new value');
});
// Exhaust task queue because setItem is scheduled as microtask
await waitFor(() => {
expect(spy).toHaveBeenCalledWith('key', JSON.stringify('new value'));
});
});
it('when no value is present in storage, calls init with undefined and null', () => {
const initialize = jest.fn(() => 'default value');
reactHooks.renderHook(
(args: Parameters<typeof useLocalStorageState>) =>
useLocalStorageState(args[0], args[1]),
{initialProps: ['key', initialize]}
);
expect(initialize).toHaveBeenCalledWith(undefined, null);
});
it('when a value is present but cannot be parsed, calls init with undefined, null', () => {
localStorageWrapper.setItem('key', JSON.stringify('invalid').slice(0, 5));
const initialize = jest.fn(() => 'default value');
reactHooks.renderHook(
(args: Parameters<typeof useLocalStorageState>) =>
useLocalStorageState(args[0], args[1]),
{initialProps: ['key', initialize]}
);
expect(initialize).toHaveBeenCalledWith(
undefined,
JSON.stringify('invalid json').slice(0, 5)
);
});
it('when a value is present but cannot be parsed init can recover', () => {
localStorageWrapper.setItem('key', JSON.stringify('invalid').slice(5, 9));
const initialize = jest.fn((_decodedValue, encodedValue) => {
const value = JSON.parse('"va' + encodedValue);
return value;
});
const {result} = reactHooks.renderHook(
(args: Parameters<typeof useLocalStorageState>) =>
useLocalStorageState(args[0], args[1]),
{initialProps: ['key', initialize]}
);
expect(result.current[0]).toBe('valid');
});
it('when a value is present, init can transform it', () => {
localStorageWrapper.setItem('key', JSON.stringify('valid json'));
const initialize = jest.fn((decodedValue, _encodedValue) => {
return 'super ' + decodedValue;
});
const {result} = reactHooks.renderHook(
(args: Parameters<typeof useLocalStorageState>) =>
useLocalStorageState(args[0], args[1]),
{initialProps: ['key', initialize]}
);
expect(result.current[0]).toBe('super valid json');
});
it('when a value is present and can be parsed, calls init with decoded and encoded value', () => {
localStorageWrapper.setItem('key', JSON.stringify('valid json'));
const initialize = jest.fn(() => 'default value');
reactHooks.renderHook(
(args: Parameters<typeof useLocalStorageState>) =>
useLocalStorageState(args[0], args[1]),
{initialProps: ['key', initialize]}
);
expect(initialize).toHaveBeenCalledWith('valid json', JSON.stringify('valid json'));
});
it('crashes with TypeError for unsupported primitives when they are recursive', () => {
const recursiveReferenceMap = new Map();
recursiveReferenceMap.set('key', recursiveReferenceMap);
jest.spyOn(window, 'queueMicrotask').mockImplementation(cb => cb());
const {result} = reactHooks.renderHook(
(args: Parameters<typeof useLocalStorageState>) =>
useLocalStorageState(args[0], args[1]),
{initialProps: ['key', recursiveReferenceMap]}
);
try {
result.current[1](recursiveReferenceMap);
} catch (e) {
expect(
e.message.startsWith(
`useLocalStorage: Native serialization of Map is not supported`
)
).toBe(true);
}
});
it('crashes with native error on recursive serialization of plain objects', () => {
const recursiveObject: Record<string, any> = {};
recursiveObject.key = recursiveObject;
jest.spyOn(window, 'queueMicrotask').mockImplementation(cb => cb());
const {result} = reactHooks.renderHook(
(args: Parameters<typeof useLocalStorageState>) =>
useLocalStorageState(args[0], args[1]),
{initialProps: ['key', recursiveObject]}
);
try {
result.current[1](recursiveObject);
} catch (e) {
expect(e.message.startsWith('Converting circular structure to JSON')).toBe(true);
}
});
it.each([
['BigInt', BigInt(1)],
['RegExp (literal)', /regex/],
['RegExp (constructor)', new RegExp('regex')],
['Map', new Map()],
['Set', new Set()],
['WeakSet', new WeakSet()],
['WeakMap', new WeakMap()],
// When invalid values are nested
['Map', {nested: new Map()}],
['Set', {nested: new Set()}],
['WeakSet', {nested: new WeakSet()}],
['WeakMap', {nested: new WeakMap()}],
])('when attempting to serialize a %s', (type, value) => {
const {result} = reactHooks.renderHook(
(args: Parameters<typeof useLocalStorageState>) =>
useLocalStorageState(args[0], args[1]),
{initialProps: ['key', value]}
);
// Immediately execute microtask so that the error is not thrown from the current execution stack and can be caught by a try/catch
jest.spyOn(window, 'queueMicrotask').mockImplementation(cb => cb());
try {
result.current[1](value);
} catch (e) {
expect(
e.message.startsWith(
`useLocalStorage: Native serialization of ${
type.split(' ')[0]
} is not supported`
)
).toBe(true);
}
});
});