useLocalStorageState.ts 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. import {useCallback, useLayoutEffect, useRef, useState} from 'react';
  2. import localStorageWrapper from 'sentry/utils/localStorage';
  3. const SUPPORTS_QUEUE_MICROTASK = window && 'queueMicrotask' in window;
  4. const SUPPORTS_LOCAL_STORAGE = window && 'localStorage' in window;
  5. function scheduleMicroTask(callback: () => void) {
  6. if (SUPPORTS_QUEUE_MICROTASK) {
  7. window.queueMicrotask(callback);
  8. } else {
  9. Promise.resolve()
  10. .then(callback)
  11. .catch(e => {
  12. // Escape the promise and throw the error so it gets reported
  13. if (window) {
  14. window.setTimeout(() => {
  15. throw e;
  16. });
  17. } else {
  18. // Best effort and just rethrow
  19. throw e;
  20. }
  21. });
  22. }
  23. }
  24. // Attempt to parse JSON. If it fails, swallow the error and return null.
  25. // As an improvement, we should maybe allow users to intercept here or possibly use
  26. // a different parsing function from JSON.parse
  27. function tryParseStorage<T>(jsonEncodedValue: string): T | null {
  28. try {
  29. return JSON.parse(jsonEncodedValue);
  30. } catch (e) {
  31. return null;
  32. }
  33. }
  34. function makeTypeExceptionString(instance: string) {
  35. 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`;
  36. }
  37. function strictReplacer<T>(_key: string, value: T): T {
  38. if (typeof BigInt !== 'undefined' && typeof value === 'bigint') {
  39. throw new TypeError(makeTypeExceptionString('BigInt'));
  40. }
  41. if (value instanceof RegExp) {
  42. throw new TypeError(makeTypeExceptionString('RegExp'));
  43. }
  44. if (typeof Map !== 'undefined' && value instanceof Map) {
  45. throw new TypeError(makeTypeExceptionString('Map'));
  46. }
  47. if (typeof Set !== 'undefined' && value instanceof Set) {
  48. throw new TypeError(makeTypeExceptionString('Set'));
  49. }
  50. if (typeof WeakMap !== 'undefined' && value instanceof WeakMap) {
  51. throw new TypeError(makeTypeExceptionString('WeakMap'));
  52. }
  53. if (typeof WeakSet !== 'undefined' && value instanceof WeakSet) {
  54. throw new TypeError(makeTypeExceptionString('WeakSet'));
  55. }
  56. return value;
  57. }
  58. function stringifyForStorage(value: unknown) {
  59. return JSON.stringify(value, strictReplacer, 0);
  60. }
  61. function defaultOrInitializer<S>(
  62. defaultValueOrInitializeFn: S | ((value?: unknown, rawStorageValue?: unknown) => S),
  63. value?: unknown,
  64. rawValue?: unknown
  65. ): S {
  66. if (typeof defaultValueOrInitializeFn === 'function') {
  67. // https://github.com/microsoft/TypeScript/issues/37663#issuecomment-759728342
  68. // @ts-expect-error
  69. return defaultValueOrInitializeFn(value, rawValue);
  70. }
  71. return value === undefined ? defaultValueOrInitializeFn : (value as S);
  72. }
  73. // Initialize state with default value or value from localStorage.
  74. // If window is not defined uses the default value and **does not** throw an error
  75. function initializeStorage<S>(
  76. key: string,
  77. defaultValueOrInitializeFn: S | ((rawStorageValue?: unknown) => S)
  78. ): S {
  79. if (typeof key !== 'string') {
  80. throw new TypeError('useLocalStorage: key must be a string');
  81. }
  82. // Return default if env does not support localStorage. Passing null to initializer
  83. // to mimick not having any previously stored value there.
  84. if (!SUPPORTS_LOCAL_STORAGE) {
  85. return defaultOrInitializer(defaultValueOrInitializeFn, undefined, null);
  86. }
  87. // getItem and try and decode it, if null is returned use default initializer
  88. const jsonEncodedValue = localStorageWrapper.getItem(key);
  89. if (jsonEncodedValue === null) {
  90. return defaultOrInitializer(defaultValueOrInitializeFn, undefined, null);
  91. }
  92. // We may have failed to parse the value, so just pass it down raw to the initializer
  93. const decodedValue = tryParseStorage<S>(jsonEncodedValue);
  94. if (decodedValue === null) {
  95. return defaultOrInitializer(defaultValueOrInitializeFn, undefined, jsonEncodedValue);
  96. }
  97. // We managed to decode the value, so use it
  98. return defaultOrInitializer(defaultValueOrInitializeFn, decodedValue, jsonEncodedValue);
  99. }
  100. // Mimicks the behavior of React.useState but keeps state synced with localStorage.
  101. // The only difference from React is that when a state initializer fn is passed,
  102. // the first argument to that function will be the value that we decoded from localStorage
  103. // and the second argument will be the raw value from localStorage. This is useful for cases where you may
  104. // want to recover the error, apply a transformation or use an alternative parsing function.
  105. export function useLocalStorageState<S>(
  106. key: string,
  107. initialState: S | ((value?: unknown, rawValue?: unknown) => S)
  108. ): [S, (value: S) => void] {
  109. const [value, setValue] = useState(() => {
  110. return initializeStorage<S>(key, initialState);
  111. });
  112. // We want to avoid a blinking state with the old value when props change, so we reinitialize the state
  113. // before the screen updates using useLayoutEffect vs useEffect. The ref prevents this from firing on mount
  114. // as the value will already be initialized from the initialState and it would be unnecessary to re-initialize
  115. const renderRef = useRef(false);
  116. useLayoutEffect(() => {
  117. if (!renderRef.current) {
  118. renderRef.current = true;
  119. return;
  120. }
  121. setValue(initializeStorage(key, initialState));
  122. // We only want to update the value when the key changes
  123. // eslint-disable-next-line react-hooks/exhaustive-deps
  124. }, [key]);
  125. const setStoredValue = useCallback(
  126. (newValue: S) => {
  127. if (typeof key !== 'string') {
  128. throw new TypeError('useLocalStorage: key must be a string');
  129. }
  130. setValue(newValue);
  131. // Not critical and we dont want to block anything after this, so fire microtask
  132. // and allow this to eventually be in sync.
  133. scheduleMicroTask(() => {
  134. localStorageWrapper.setItem(key, stringifyForStorage(newValue));
  135. });
  136. },
  137. [key]
  138. );
  139. return [value, setStoredValue];
  140. }