useLocalStorageState.spec.tsx 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. import {reactHooks, waitFor} from 'sentry-test/reactTestingLibrary';
  2. import localStorageWrapper from 'sentry/utils/localStorage';
  3. import {useLocalStorageState} from 'sentry/utils/useLocalStorageState';
  4. describe('useLocalStorageState', () => {
  5. beforeEach(() => {
  6. localStorageWrapper.clear();
  7. if (localStorageWrapper.length > 0) {
  8. throw new Error('localStorage was not cleared');
  9. }
  10. });
  11. it('throws if key is not a string', async () => {
  12. let errorResult!: TypeError;
  13. reactHooks.renderHook(() => {
  14. try {
  15. // @ts-expect-error force incorrect usage
  16. // biome-ignore lint/correctness/useHookAtTopLevel: <explanation>
  17. useLocalStorageState({}, 'default value');
  18. } catch (err) {
  19. errorResult = err;
  20. }
  21. });
  22. await waitFor(() => expect(errorResult).toBeInstanceOf(TypeError));
  23. expect(errorResult.message).toBe('useLocalStorage: key must be a string');
  24. });
  25. it('initialized with value', () => {
  26. const {result} = reactHooks.renderHook(
  27. (args: Parameters<typeof useLocalStorageState>) =>
  28. useLocalStorageState(args[0], args[1]),
  29. {initialProps: ['key', 'default value']}
  30. );
  31. expect(result.current[0]).toBe('default value');
  32. });
  33. it('initializes with init fn', () => {
  34. const initialize = jest.fn(() => 'default value');
  35. const {result} = reactHooks.renderHook(
  36. (args: Parameters<typeof useLocalStorageState>) =>
  37. useLocalStorageState(args[0], args[1]),
  38. {initialProps: ['key', initialize]}
  39. );
  40. expect(initialize).toHaveBeenCalled();
  41. expect(result.current[0]).toBe('default value');
  42. });
  43. it('initializes with default value', () => {
  44. localStorageWrapper.setItem('key', JSON.stringify('initial storage value'));
  45. const {result} = reactHooks.renderHook(
  46. (args: Parameters<typeof useLocalStorageState>) =>
  47. useLocalStorageState(args[0], args[1]),
  48. {initialProps: ['key', 'default value']}
  49. );
  50. expect(result.current[0]).toBe('initial storage value');
  51. });
  52. it('sets new value', () => {
  53. const {result} = reactHooks.renderHook(
  54. (args: Parameters<typeof useLocalStorageState>) =>
  55. useLocalStorageState(args[0], args[1]),
  56. {initialProps: ['key', 'default value']}
  57. );
  58. reactHooks.act(() => {
  59. result.current[1]('new value');
  60. });
  61. expect(result.current[0]).toBe('new value');
  62. });
  63. it('updates localstorage value', async () => {
  64. const {result} = reactHooks.renderHook(
  65. (args: Parameters<typeof useLocalStorageState>) =>
  66. useLocalStorageState(args[0], args[1]),
  67. {initialProps: ['key', 'default value']}
  68. );
  69. const spy = jest.spyOn(Storage.prototype, 'setItem');
  70. reactHooks.act(() => {
  71. result.current[1]('new value');
  72. });
  73. // Exhaust task queue because setItem is scheduled as microtask
  74. await waitFor(() => {
  75. expect(spy).toHaveBeenCalledWith('key', JSON.stringify('new value'));
  76. });
  77. });
  78. it('when no value is present in storage, calls init with undefined and null', () => {
  79. const initialize = jest.fn(() => 'default value');
  80. reactHooks.renderHook(
  81. (args: Parameters<typeof useLocalStorageState>) =>
  82. useLocalStorageState(args[0], args[1]),
  83. {initialProps: ['key', initialize]}
  84. );
  85. expect(initialize).toHaveBeenCalledWith(undefined, null);
  86. });
  87. it('when a value is present but cannot be parsed, calls init with undefined, null', () => {
  88. localStorageWrapper.setItem('key', JSON.stringify('invalid').slice(0, 5));
  89. const initialize = jest.fn(() => 'default value');
  90. reactHooks.renderHook(
  91. (args: Parameters<typeof useLocalStorageState>) =>
  92. useLocalStorageState(args[0], args[1]),
  93. {initialProps: ['key', initialize]}
  94. );
  95. expect(initialize).toHaveBeenCalledWith(
  96. undefined,
  97. JSON.stringify('invalid json').slice(0, 5)
  98. );
  99. });
  100. it('when a value is present but cannot be parsed init can recover', () => {
  101. localStorageWrapper.setItem('key', JSON.stringify('invalid').slice(5, 9));
  102. const initialize = jest.fn((_decodedValue, encodedValue) => {
  103. const value = JSON.parse('"va' + encodedValue);
  104. return value;
  105. });
  106. const {result} = reactHooks.renderHook(
  107. (args: Parameters<typeof useLocalStorageState>) =>
  108. useLocalStorageState(args[0], args[1]),
  109. {initialProps: ['key', initialize]}
  110. );
  111. expect(result.current[0]).toBe('valid');
  112. });
  113. it('when a value is present, init can transform it', () => {
  114. localStorageWrapper.setItem('key', JSON.stringify('valid json'));
  115. const initialize = jest.fn((decodedValue, _encodedValue) => {
  116. return 'super ' + decodedValue;
  117. });
  118. const {result} = reactHooks.renderHook(
  119. (args: Parameters<typeof useLocalStorageState>) =>
  120. useLocalStorageState(args[0], args[1]),
  121. {initialProps: ['key', initialize]}
  122. );
  123. expect(result.current[0]).toBe('super valid json');
  124. });
  125. it('when a value is present and can be parsed, calls init with decoded and encoded value', () => {
  126. localStorageWrapper.setItem('key', JSON.stringify('valid json'));
  127. const initialize = jest.fn(() => 'default value');
  128. reactHooks.renderHook(
  129. (args: Parameters<typeof useLocalStorageState>) =>
  130. useLocalStorageState(args[0], args[1]),
  131. {initialProps: ['key', initialize]}
  132. );
  133. expect(initialize).toHaveBeenCalledWith('valid json', JSON.stringify('valid json'));
  134. });
  135. it('crashes with TypeError for unsupported primitives when they are recursive', () => {
  136. const recursiveReferenceMap = new Map();
  137. recursiveReferenceMap.set('key', recursiveReferenceMap);
  138. jest.spyOn(window, 'queueMicrotask').mockImplementation(cb => cb());
  139. const {result} = reactHooks.renderHook(
  140. (args: Parameters<typeof useLocalStorageState>) =>
  141. useLocalStorageState(args[0], args[1]),
  142. {initialProps: ['key', recursiveReferenceMap]}
  143. );
  144. try {
  145. result.current[1](recursiveReferenceMap);
  146. } catch (e) {
  147. expect(
  148. e.message.startsWith(
  149. `useLocalStorage: Native serialization of Map is not supported`
  150. )
  151. ).toBe(true);
  152. }
  153. });
  154. it('crashes with native error on recursive serialization of plain objects', () => {
  155. const recursiveObject: Record<string, any> = {};
  156. recursiveObject.key = recursiveObject;
  157. jest.spyOn(window, 'queueMicrotask').mockImplementation(cb => cb());
  158. const {result} = reactHooks.renderHook(
  159. (args: Parameters<typeof useLocalStorageState>) =>
  160. useLocalStorageState(args[0], args[1]),
  161. {initialProps: ['key', recursiveObject]}
  162. );
  163. try {
  164. result.current[1](recursiveObject);
  165. } catch (e) {
  166. expect(e.message.startsWith('Converting circular structure to JSON')).toBe(true);
  167. }
  168. });
  169. it.each([
  170. ['BigInt', BigInt(1)],
  171. ['RegExp (literal)', /regex/],
  172. ['RegExp (constructor)', new RegExp('regex')],
  173. ['Map', new Map()],
  174. ['Set', new Set()],
  175. ['WeakSet', new WeakSet()],
  176. ['WeakMap', new WeakMap()],
  177. // When invalid values are nested
  178. ['Map', {nested: new Map()}],
  179. ['Set', {nested: new Set()}],
  180. ['WeakSet', {nested: new WeakSet()}],
  181. ['WeakMap', {nested: new WeakMap()}],
  182. ])('when attempting to serialize a %s', (type, value) => {
  183. const {result} = reactHooks.renderHook(
  184. (args: Parameters<typeof useLocalStorageState>) =>
  185. useLocalStorageState(args[0], args[1]),
  186. {initialProps: ['key', value]}
  187. );
  188. // Immediately execute microtask so that the error is not thrown from the current execution stack and can be caught by a try/catch
  189. jest.spyOn(window, 'queueMicrotask').mockImplementation(cb => cb());
  190. try {
  191. result.current[1](value);
  192. } catch (e) {
  193. expect(
  194. e.message.startsWith(
  195. `useLocalStorage: Native serialization of ${
  196. type.split(' ')[0]
  197. } is not supported`
  198. )
  199. ).toBe(true);
  200. }
  201. });
  202. });