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

161 lines
5.9 KiB
TypeScript

import {useCallback, useLayoutEffect, useRef, useState} from 'react';
import localStorageWrapper from 'sentry/utils/localStorage';
const SUPPORTS_QUEUE_MICROTASK = window && 'queueMicrotask' in window;
const SUPPORTS_LOCAL_STORAGE = window && 'localStorage' in window;
function scheduleMicroTask(callback: () => void) {
if (SUPPORTS_QUEUE_MICROTASK) {
window.queueMicrotask(callback);
} else {
Promise.resolve()
.then(callback)
.catch(e => {
// Escape the promise and throw the error so it gets reported
if (window) {
window.setTimeout(() => {
throw e;
});
} else {
// Best effort and just rethrow
throw e;
}
});
}
}
// Attempt to parse JSON. If it fails, swallow the error and return null.
// As an improvement, we should maybe allow users to intercept here or possibly use
// a different parsing function from JSON.parse
function tryParseStorage<T>(jsonEncodedValue: string): T | null {
try {
return JSON.parse(jsonEncodedValue);
} catch (e) {
return null;
}
}
function makeTypeExceptionString(instance: string) {
return `useLocalStorage: Native serialization of ${instance} is not supported. You are attempting to serialize a ${instance} instance this data will be lost. For more info, see how ${instance.toLowerCase()}s are serialized https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#examples`;
}
function strictReplacer<T>(_key: string, value: T): T {
if (typeof BigInt !== 'undefined' && typeof value === 'bigint') {
throw new TypeError(makeTypeExceptionString('BigInt'));
}
if (value instanceof RegExp) {
throw new TypeError(makeTypeExceptionString('RegExp'));
}
if (typeof Map !== 'undefined' && value instanceof Map) {
throw new TypeError(makeTypeExceptionString('Map'));
}
if (typeof Set !== 'undefined' && value instanceof Set) {
throw new TypeError(makeTypeExceptionString('Set'));
}
if (typeof WeakMap !== 'undefined' && value instanceof WeakMap) {
throw new TypeError(makeTypeExceptionString('WeakMap'));
}
if (typeof WeakSet !== 'undefined' && value instanceof WeakSet) {
throw new TypeError(makeTypeExceptionString('WeakSet'));
}
return value;
}
function stringifyForStorage(value: unknown) {
return JSON.stringify(value, strictReplacer, 0);
}
function defaultOrInitializer<S>(
defaultValueOrInitializeFn: S | ((value?: unknown, rawStorageValue?: unknown) => S),
value?: unknown,
rawValue?: unknown
): S {
if (typeof defaultValueOrInitializeFn === 'function') {
// https://github.com/microsoft/TypeScript/issues/37663#issuecomment-759728342
// @ts-expect-error
return defaultValueOrInitializeFn(value, rawValue);
}
return value === undefined ? defaultValueOrInitializeFn : (value as S);
}
// Initialize state with default value or value from localStorage.
// If window is not defined uses the default value and **does not** throw an error
function initializeStorage<S>(
key: string,
defaultValueOrInitializeFn: S | ((rawStorageValue?: unknown) => S)
): S {
if (typeof key !== 'string') {
throw new TypeError('useLocalStorage: key must be a string');
}
// Return default if env does not support localStorage. Passing null to initializer
// to mimick not having any previously stored value there.
if (!SUPPORTS_LOCAL_STORAGE) {
return defaultOrInitializer(defaultValueOrInitializeFn, undefined, null);
}
// getItem and try and decode it, if null is returned use default initializer
const jsonEncodedValue = localStorageWrapper.getItem(key);
if (jsonEncodedValue === null) {
return defaultOrInitializer(defaultValueOrInitializeFn, undefined, null);
}
// We may have failed to parse the value, so just pass it down raw to the initializer
const decodedValue = tryParseStorage<S>(jsonEncodedValue);
if (decodedValue === null) {
return defaultOrInitializer(defaultValueOrInitializeFn, undefined, jsonEncodedValue);
}
// We managed to decode the value, so use it
return defaultOrInitializer(defaultValueOrInitializeFn, decodedValue, jsonEncodedValue);
}
// Mimicks the behavior of React.useState but keeps state synced with localStorage.
// The only difference from React is that when a state initializer fn is passed,
// the first argument to that function will be the value that we decoded from localStorage
// and the second argument will be the raw value from localStorage. This is useful for cases where you may
// want to recover the error, apply a transformation or use an alternative parsing function.
export function useLocalStorageState<S>(
key: string,
initialState: S | ((value?: unknown, rawValue?: unknown) => S)
): [S, (value: S) => void] {
const [value, setValue] = useState(() => {
return initializeStorage<S>(key, initialState);
});
// We want to avoid a blinking state with the old value when props change, so we reinitialize the state
// before the screen updates using useLayoutEffect vs useEffect. The ref prevents this from firing on mount
// as the value will already be initialized from the initialState and it would be unnecessary to re-initialize
const renderRef = useRef(false);
useLayoutEffect(() => {
if (!renderRef.current) {
renderRef.current = true;
return;
}
setValue(initializeStorage(key, initialState));
// We only want to update the value when the key changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
const setStoredValue = useCallback(
(newValue: S) => {
if (typeof key !== 'string') {
throw new TypeError('useLocalStorage: key must be a string');
}
setValue(newValue);
// Not critical and we dont want to block anything after this, so fire microtask
// and allow this to eventually be in sync.
scheduleMicroTask(() => {
localStorageWrapper.setItem(key, stringifyForStorage(newValue));
});
},
[key]
);
return [value, setStoredValue];
}