useLocalStorageState.spec.tsx 7.6 KB

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