@hubspot/ui-extensions 0.12.4 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/dist/__generated__/version.d.ts +2 -0
  2. package/dist/__generated__/version.js +4 -0
  3. package/dist/__tests__/crm/hooks/useAssociations.spec.js +79 -0
  4. package/dist/__tests__/crm/utils/fetchAssociations.spec.js +0 -10
  5. package/dist/__tests__/experimental/hooks/useCrmSearch.spec.js +586 -0
  6. package/dist/__tests__/experimental/hooks/utils/fetchCrmSearch.spec.js +217 -0
  7. package/dist/__tests__/hooks/useDebounce.spec.js +123 -0
  8. package/dist/__tests__/hooks/utils/useFetchLifecycle.spec.js +324 -0
  9. package/dist/__tests__/internal/hook-utils.spec.js +17 -0
  10. package/dist/__tests__/test-d/extension-points.test-d.js +1 -0
  11. package/dist/crm/hooks/useAssociations.d.ts +3 -3
  12. package/dist/crm/hooks/useAssociations.js +55 -139
  13. package/dist/crm/hooks/useCrmProperties.js +29 -125
  14. package/dist/crm/utils/fetchAssociations.js +0 -3
  15. package/dist/experimental/hooks/useCrmSearch.d.ts +2 -0
  16. package/dist/experimental/hooks/useCrmSearch.js +206 -0
  17. package/dist/experimental/hooks/utils/fetchCrmSearch.d.ts +6 -0
  18. package/dist/experimental/hooks/utils/fetchCrmSearch.js +39 -0
  19. package/dist/experimental/index.d.ts +1 -0
  20. package/dist/experimental/index.js +1 -0
  21. package/dist/hooks/useDebounce.d.ts +19 -0
  22. package/dist/hooks/useDebounce.js +32 -0
  23. package/dist/hooks/utils/useFetchLifecycle.d.ts +35 -0
  24. package/dist/hooks/utils/useFetchLifecycle.js +103 -0
  25. package/dist/index.d.ts +1 -0
  26. package/dist/index.js +1 -0
  27. package/dist/internal/hook-utils.d.ts +6 -0
  28. package/dist/internal/hook-utils.js +16 -1
  29. package/dist/{experimental/pages → pages}/components/page-routes.d.ts +1 -2
  30. package/dist/{experimental/pages → pages}/components/page-routes.js +0 -4
  31. package/dist/{experimental/pages → pages}/create-page-router.d.ts +1 -3
  32. package/dist/{experimental/pages → pages}/create-page-router.js +1 -3
  33. package/dist/{experimental/pages → pages}/create-page-router.test.js +2 -2
  34. package/dist/{experimental/pages → pages}/hooks.d.ts +0 -1
  35. package/dist/{experimental/pages → pages}/hooks.js +0 -1
  36. package/dist/pages/index.d.ts +6 -1
  37. package/dist/pages/index.js +4 -1
  38. package/dist/{experimental/pages → pages}/internal/page-router-internal-types.d.ts +1 -1
  39. package/dist/pages/internal/useAppPageLocation.d.ts +1 -0
  40. package/dist/{experimental/pages → pages}/internal/useAppPageLocation.js +2 -2
  41. package/dist/{experimental/pages → pages}/types.d.ts +1 -2
  42. package/dist/pages/types.js +1 -0
  43. package/dist/shared/types/actions.d.ts +12 -2
  44. package/dist/shared/types/components/table.d.ts +16 -0
  45. package/dist/shared/types/context.d.ts +6 -0
  46. package/dist/shared/types/crm.d.ts +4 -0
  47. package/dist/shared/types/experimental.d.ts +1 -1
  48. package/dist/shared/types/extension-points.d.ts +9 -3
  49. package/dist/shared/types/extension-points.js +1 -0
  50. package/dist/shared/types/hooks.d.ts +72 -0
  51. package/dist/shared/types/hooks.js +1 -0
  52. package/dist/shared/types/shared.d.ts +8 -0
  53. package/dist/shared/types/worker-globals.d.ts +5 -0
  54. package/dist/testing/__tests__/createRenderer.spec.js +1 -1
  55. package/dist/testing/internal/mocks/index.d.ts +1 -1
  56. package/dist/testing/internal/mocks/mock-extension-point-api.js +21 -1
  57. package/dist/testing/internal/mocks/mock-hooks.js +26 -0
  58. package/dist/testing/internal/types-internal.d.ts +2 -0
  59. package/dist/testing/types.d.ts +11 -1
  60. package/dist/utils/pagination.d.ts +0 -9
  61. package/package.json +3 -2
  62. package/dist/experimental/pages/index.d.ts +0 -6
  63. package/dist/experimental/pages/index.js +0 -4
  64. package/dist/experimental/pages/internal/useAppPageLocation.d.ts +0 -1
  65. package/dist/shared/types/pages/app-pages-types.d.ts +0 -75
  66. package/dist/shared/types/pages/components/page-routes.d.ts +0 -115
  67. package/dist/shared/types/pages/index.d.ts +0 -1
  68. package/dist/shared/types/pages.d.ts +0 -1
  69. /package/dist/{experimental/pages/create-page-router.test.d.ts → __tests__/experimental/hooks/useCrmSearch.spec.d.ts} +0 -0
  70. /package/dist/{experimental/pages/internal/trie-router.test.d.ts → __tests__/experimental/hooks/utils/fetchCrmSearch.spec.d.ts} +0 -0
  71. /package/dist/{experimental/pages/types.js → __tests__/hooks/useDebounce.spec.d.ts} +0 -0
  72. /package/dist/{shared/types/pages.js → __tests__/hooks/utils/useFetchLifecycle.spec.d.ts} +0 -0
  73. /package/dist/{shared/types/pages/app-pages-types.js → __tests__/internal/hook-utils.spec.d.ts} +0 -0
  74. /package/dist/{experimental/pages → pages}/components/index.d.ts +0 -0
  75. /package/dist/{experimental/pages → pages}/components/index.js +0 -0
  76. /package/dist/{shared/types/pages/components/page-routes.js → pages/create-page-router.test.d.ts} +0 -0
  77. /package/dist/{experimental/pages → pages}/internal/app-page-route-context.d.ts +0 -0
  78. /package/dist/{experimental/pages → pages}/internal/app-page-route-context.js +0 -0
  79. /package/dist/{experimental/pages → pages}/internal/convert-page-routes-react-elements.d.ts +0 -0
  80. /package/dist/{experimental/pages → pages}/internal/convert-page-routes-react-elements.js +0 -0
  81. /package/dist/{experimental/pages → pages}/internal/page-router-internal-types.js +0 -0
  82. /package/dist/{experimental/pages → pages}/internal/trie-router.d.ts +0 -0
  83. /package/dist/{experimental/pages → pages}/internal/trie-router.js +0 -0
  84. /package/dist/{shared/types/pages/index.js → pages/internal/trie-router.test.d.ts} +0 -0
  85. /package/dist/{experimental/pages → pages}/internal/trie-router.test.js +0 -0
@@ -0,0 +1,217 @@
1
+ import { vi } from 'vitest';
2
+ import { fetchCrmSearch } from "../../../../experimental/hooks/utils/fetchCrmSearch.js";
3
+ const mockFetchCrmSearch = vi.fn();
4
+ // Mock the global self object to match the hsWorkerAPI shape
5
+ Object.defineProperty(global, 'self', {
6
+ value: {
7
+ __HUBSPOT_EXTENSION_WORKER__: true,
8
+ hsWorkerAPI: {
9
+ fetchCrmSearch: mockFetchCrmSearch,
10
+ },
11
+ },
12
+ writable: true,
13
+ });
14
+ describe('fetchCrmSearch', () => {
15
+ const createMockResponse = (data, overrides = {}) => ({
16
+ ok: true,
17
+ json: vi.fn().mockResolvedValue({ data, cleanup: vi.fn() }),
18
+ ...overrides,
19
+ });
20
+ const createBasicRequest = (overrides = {}) => ({
21
+ objectType: '0-1',
22
+ properties: ['firstname', 'lastname'],
23
+ ...overrides,
24
+ });
25
+ const createValidResponseData = (overrides = {}) => ({
26
+ results: [
27
+ {
28
+ objectId: 1001,
29
+ properties: { firstname: 'John', lastname: 'Doe' },
30
+ },
31
+ ],
32
+ total: 1,
33
+ hasMore: false,
34
+ ...overrides,
35
+ });
36
+ beforeEach(() => {
37
+ vi.clearAllMocks();
38
+ });
39
+ describe('basic functionality', () => {
40
+ it('should successfully fetch CRM search results', async () => {
41
+ const data = createValidResponseData();
42
+ mockFetchCrmSearch.mockResolvedValueOnce(createMockResponse(data));
43
+ const request = createBasicRequest();
44
+ const result = await fetchCrmSearch(request);
45
+ expect(result).toHaveProperty('data');
46
+ expect(result).toHaveProperty('cleanup');
47
+ expect(typeof result.cleanup).toBe('function');
48
+ expect(result.data).toEqual(data);
49
+ });
50
+ it('should return a Promise', () => {
51
+ mockFetchCrmSearch.mockResolvedValueOnce(createMockResponse(createValidResponseData()));
52
+ const result = fetchCrmSearch(createBasicRequest());
53
+ expect(result).toBeInstanceOf(Promise);
54
+ });
55
+ it('should support all request fields (query, filterGroups, sorts, pageSize, after)', async () => {
56
+ mockFetchCrmSearch.mockResolvedValueOnce(createMockResponse(createValidResponseData({ hasMore: true, after: 'cursor-2' })));
57
+ const request = {
58
+ objectType: '0-1',
59
+ properties: ['firstname'],
60
+ query: 'john',
61
+ filterGroups: [
62
+ {
63
+ filters: [
64
+ { propertyName: 'lifecyclestage', operator: 'EQ', value: 'lead' },
65
+ ],
66
+ },
67
+ ],
68
+ sorts: [
69
+ { propertyName: 'createdate', direction: 'DESCENDING' },
70
+ ],
71
+ pageSize: 25,
72
+ after: 'cursor-1',
73
+ };
74
+ await fetchCrmSearch(request);
75
+ expect(mockFetchCrmSearch).toHaveBeenCalledWith(request, undefined);
76
+ });
77
+ });
78
+ describe('error handling', () => {
79
+ it('should throw an error when fetch fails', async () => {
80
+ mockFetchCrmSearch.mockRejectedValueOnce(new Error('Network error'));
81
+ await expect(fetchCrmSearch(createBasicRequest())).rejects.toThrow('Network error');
82
+ });
83
+ it('should handle unknown errors with a default message', async () => {
84
+ mockFetchCrmSearch.mockRejectedValueOnce('Unknown error');
85
+ await expect(fetchCrmSearch(createBasicRequest())).rejects.toThrow('Failed to fetch CRM search results: Unknown error');
86
+ });
87
+ it('should throw when server returns an error field', async () => {
88
+ mockFetchCrmSearch.mockResolvedValueOnce({
89
+ ok: true,
90
+ json: vi.fn().mockResolvedValue({
91
+ error: 'Permission denied',
92
+ cleanup: vi.fn(),
93
+ }),
94
+ });
95
+ await expect(fetchCrmSearch(createBasicRequest())).rejects.toThrow('Permission denied');
96
+ });
97
+ });
98
+ describe('response validation', () => {
99
+ it('should throw error when results field is missing', async () => {
100
+ mockFetchCrmSearch.mockResolvedValueOnce(createMockResponse({ total: 0, hasMore: false }) // missing results
101
+ );
102
+ await expect(fetchCrmSearch(createBasicRequest())).rejects.toThrow('Invalid response format');
103
+ });
104
+ it('should throw error when results is not an array', async () => {
105
+ mockFetchCrmSearch.mockResolvedValueOnce(createMockResponse({
106
+ results: 'not an array',
107
+ total: 0,
108
+ hasMore: false,
109
+ }));
110
+ await expect(fetchCrmSearch(createBasicRequest())).rejects.toThrow('Invalid response format');
111
+ });
112
+ it('should throw error when total is not a number', async () => {
113
+ mockFetchCrmSearch.mockResolvedValueOnce(createMockResponse({ results: [], total: 'zero', hasMore: false }));
114
+ await expect(fetchCrmSearch(createBasicRequest())).rejects.toThrow('Invalid response format');
115
+ });
116
+ it('should throw error when hasMore is not a boolean', async () => {
117
+ mockFetchCrmSearch.mockResolvedValueOnce(createMockResponse({ results: [], total: 0, hasMore: 'yes' }));
118
+ await expect(fetchCrmSearch(createBasicRequest())).rejects.toThrow('Invalid response format');
119
+ });
120
+ it('should throw error when data is null', async () => {
121
+ mockFetchCrmSearch.mockResolvedValueOnce(createMockResponse(null));
122
+ await expect(fetchCrmSearch(createBasicRequest())).rejects.toThrow('Invalid response format');
123
+ });
124
+ it('should throw error when a result is missing objectId', async () => {
125
+ mockFetchCrmSearch.mockResolvedValueOnce(createMockResponse({
126
+ results: [{ properties: {} }],
127
+ total: 1,
128
+ hasMore: false,
129
+ }));
130
+ await expect(fetchCrmSearch(createBasicRequest())).rejects.toThrow('Invalid response format');
131
+ });
132
+ it('should throw error when a result objectId is not a number', async () => {
133
+ mockFetchCrmSearch.mockResolvedValueOnce(createMockResponse({
134
+ results: [{ objectId: '1001', properties: {} }],
135
+ total: 1,
136
+ hasMore: false,
137
+ }));
138
+ await expect(fetchCrmSearch(createBasicRequest())).rejects.toThrow('Invalid response format');
139
+ });
140
+ it('should throw error when a result properties field is not an object', async () => {
141
+ mockFetchCrmSearch.mockResolvedValueOnce(createMockResponse({
142
+ results: [{ objectId: 1, properties: null }],
143
+ total: 1,
144
+ hasMore: false,
145
+ }));
146
+ await expect(fetchCrmSearch(createBasicRequest())).rejects.toThrow('Invalid response format');
147
+ });
148
+ });
149
+ describe('API integration', () => {
150
+ it('should call hsWorkerAPI.fetchCrmSearch with correct parameters and no options', async () => {
151
+ mockFetchCrmSearch.mockResolvedValueOnce(createMockResponse(createValidResponseData()));
152
+ const request = createBasicRequest({ pageSize: 50 });
153
+ await fetchCrmSearch(request);
154
+ expect(mockFetchCrmSearch).toHaveBeenCalledWith(request, undefined);
155
+ });
156
+ it('should return the cleanup function provided by the API response', async () => {
157
+ const mockCleanup = vi.fn();
158
+ mockFetchCrmSearch.mockResolvedValueOnce({
159
+ ok: true,
160
+ json: vi.fn().mockResolvedValue({
161
+ data: createValidResponseData(),
162
+ cleanup: mockCleanup,
163
+ }),
164
+ });
165
+ const result = await fetchCrmSearch(createBasicRequest());
166
+ expect(result.cleanup).toBe(mockCleanup);
167
+ result.cleanup();
168
+ expect(mockCleanup).toHaveBeenCalledTimes(1);
169
+ });
170
+ it('should provide a default cleanup function when none is provided', async () => {
171
+ mockFetchCrmSearch.mockResolvedValueOnce({
172
+ ok: true,
173
+ json: vi.fn().mockResolvedValue({ data: createValidResponseData() }),
174
+ });
175
+ const result = await fetchCrmSearch(createBasicRequest());
176
+ expect(typeof result.cleanup).toBe('function');
177
+ expect(() => result.cleanup()).not.toThrow();
178
+ });
179
+ });
180
+ describe('options handling', () => {
181
+ it('should pass formatting options to the underlying fetch function', async () => {
182
+ mockFetchCrmSearch.mockResolvedValueOnce(createMockResponse(createValidResponseData()));
183
+ const options = {
184
+ propertiesToFormat: ['firstname'],
185
+ formattingOptions: {
186
+ date: { format: 'MM/dd/yyyy' },
187
+ currency: { addSymbol: true },
188
+ },
189
+ };
190
+ await fetchCrmSearch(createBasicRequest(), options);
191
+ expect(mockFetchCrmSearch).toHaveBeenCalledWith(createBasicRequest(), options);
192
+ });
193
+ it('should handle propertiesToFormat set to "all"', async () => {
194
+ mockFetchCrmSearch.mockResolvedValueOnce(createMockResponse(createValidResponseData()));
195
+ const options = {
196
+ propertiesToFormat: 'all',
197
+ formattingOptions: { dateTime: { relative: true } },
198
+ };
199
+ await fetchCrmSearch(createBasicRequest(), options);
200
+ expect(mockFetchCrmSearch).toHaveBeenCalledWith(createBasicRequest(), options);
201
+ });
202
+ it('should preserve error handling with formatting options', async () => {
203
+ mockFetchCrmSearch.mockRejectedValueOnce(new Error('Network error'));
204
+ const options = {
205
+ propertiesToFormat: ['firstname'],
206
+ formattingOptions: { date: { format: 'MM/dd/yyyy' } },
207
+ };
208
+ await expect(fetchCrmSearch(createBasicRequest(), options)).rejects.toThrow('Network error');
209
+ expect(mockFetchCrmSearch).toHaveBeenCalledWith(createBasicRequest(), options);
210
+ });
211
+ it('should preserve response validation with formatting options', async () => {
212
+ mockFetchCrmSearch.mockResolvedValueOnce(createMockResponse('Invalid response') // data should be an object
213
+ );
214
+ await expect(fetchCrmSearch(createBasicRequest(), { propertiesToFormat: 'all' })).rejects.toThrow('Invalid response format');
215
+ });
216
+ });
217
+ });
@@ -0,0 +1,123 @@
1
+ import { renderHook } from '@testing-library/react';
2
+ import { act } from 'react';
3
+ import { vi } from 'vitest';
4
+ import { useDebounce } from "../../hooks/useDebounce.js";
5
+ describe('useDebounce', () => {
6
+ beforeEach(() => {
7
+ vi.useFakeTimers();
8
+ });
9
+ afterEach(() => {
10
+ vi.useRealTimers();
11
+ });
12
+ it('should return the initial value immediately', () => {
13
+ const { result } = renderHook(() => useDebounce('hello', 300));
14
+ expect(result.current).toBe('hello');
15
+ });
16
+ it('should not update the value before the delay has elapsed', () => {
17
+ const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), { initialProps: { value: 'initial', delay: 300 } });
18
+ rerender({ value: 'updated', delay: 300 });
19
+ act(() => {
20
+ vi.advanceTimersByTime(299);
21
+ });
22
+ expect(result.current).toBe('initial');
23
+ });
24
+ it('should update the value after the delay has elapsed', () => {
25
+ const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), { initialProps: { value: 'initial', delay: 300 } });
26
+ rerender({ value: 'updated', delay: 300 });
27
+ act(() => {
28
+ vi.advanceTimersByTime(300);
29
+ });
30
+ expect(result.current).toBe('updated');
31
+ });
32
+ it('should only propagate the last value when changed rapidly', () => {
33
+ const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), { initialProps: { value: 'a', delay: 300 } });
34
+ rerender({ value: 'ab', delay: 300 });
35
+ act(() => {
36
+ vi.advanceTimersByTime(100);
37
+ });
38
+ rerender({ value: 'abc', delay: 300 });
39
+ act(() => {
40
+ vi.advanceTimersByTime(300);
41
+ });
42
+ expect(result.current).toBe('abc');
43
+ });
44
+ it('should reset the timer when the delay changes', () => {
45
+ const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), { initialProps: { value: 'initial', delay: 300 } });
46
+ rerender({ value: 'updated', delay: 300 });
47
+ act(() => {
48
+ vi.advanceTimersByTime(200);
49
+ });
50
+ // Change the delay — timer should restart
51
+ rerender({ value: 'updated', delay: 500 });
52
+ act(() => {
53
+ vi.advanceTimersByTime(300);
54
+ });
55
+ // 200 + 300 = 500ms total, but timer restarted at 200ms so only 300ms of 500ms elapsed
56
+ expect(result.current).toBe('initial');
57
+ act(() => {
58
+ vi.advanceTimersByTime(200);
59
+ });
60
+ expect(result.current).toBe('updated');
61
+ });
62
+ it('should clean up the timeout on unmount', () => {
63
+ const { result, rerender, unmount } = renderHook(({ value, delay }) => useDebounce(value, delay), { initialProps: { value: 'initial', delay: 300 } });
64
+ rerender({ value: 'updated', delay: 300 });
65
+ unmount();
66
+ // Advancing timers after unmount should not cause errors
67
+ act(() => {
68
+ vi.advanceTimersByTime(300);
69
+ });
70
+ expect(result.current).toBe('initial');
71
+ });
72
+ it('should work with numeric values', () => {
73
+ const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), { initialProps: { value: 0, delay: 200 } });
74
+ rerender({ value: 42, delay: 200 });
75
+ act(() => {
76
+ vi.advanceTimersByTime(200);
77
+ });
78
+ expect(result.current).toBe(42);
79
+ });
80
+ it('should not reset the timer when an object value is deeply equal', () => {
81
+ const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), { initialProps: { value: { query: 'a' }, delay: 300 } });
82
+ // Change the value
83
+ rerender({ value: { query: 'ab' }, delay: 300 });
84
+ act(() => {
85
+ vi.advanceTimersByTime(200);
86
+ });
87
+ // Pass a new reference with the same content — should NOT reset the timer
88
+ rerender({ value: { query: 'ab' }, delay: 300 });
89
+ act(() => {
90
+ vi.advanceTimersByTime(100);
91
+ });
92
+ // 200 + 100 = 300ms total — should have fired because the new reference didn't reset
93
+ expect(result.current).toEqual({ query: 'ab' });
94
+ });
95
+ it('should debounce when object content actually changes', () => {
96
+ const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), { initialProps: { value: { query: 'a' }, delay: 300 } });
97
+ rerender({ value: { query: 'ab' }, delay: 300 });
98
+ act(() => {
99
+ vi.advanceTimersByTime(200);
100
+ });
101
+ // Actual content change — should reset the timer
102
+ rerender({ value: { query: 'abc' }, delay: 300 });
103
+ act(() => {
104
+ vi.advanceTimersByTime(200);
105
+ });
106
+ // Only 200ms since the real change — should still be the initial value
107
+ expect(result.current).toEqual({ query: 'a' });
108
+ act(() => {
109
+ vi.advanceTimersByTime(100);
110
+ });
111
+ expect(result.current).toEqual({ query: 'abc' });
112
+ });
113
+ it('should still defer by one render cycle when delayMs is 0', () => {
114
+ const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), { initialProps: { value: 'initial', delay: 0 } });
115
+ rerender({ value: 'updated', delay: 0 });
116
+ // useEffect is async, so even with delay 0 the value is stale for one cycle
117
+ expect(result.current).toBe('initial');
118
+ act(() => {
119
+ vi.advanceTimersByTime(0);
120
+ });
121
+ expect(result.current).toBe('updated');
122
+ });
123
+ });
@@ -0,0 +1,324 @@
1
+ import { renderHook, waitFor } from '@testing-library/react';
2
+ import { vi } from 'vitest';
3
+ import { useFetchLifecycle, } from "../../../hooks/utils/useFetchLifecycle.js";
4
+ function createMockCallbacks() {
5
+ return {
6
+ onStart: vi.fn(),
7
+ onSuccess: vi.fn(),
8
+ onError: vi.fn(),
9
+ };
10
+ }
11
+ describe('useFetchLifecycle', () => {
12
+ let originalError;
13
+ beforeAll(() => {
14
+ // Suppress React act() warnings coming from @testing-library/react
15
+ originalError = console.error;
16
+ console.error = (...args) => {
17
+ if (typeof args[0] === 'string' &&
18
+ (args[0].includes('ReactDOMTestUtils.act') ||
19
+ args[0].includes('was not wrapped in act')))
20
+ return;
21
+ originalError.call(console, ...args);
22
+ };
23
+ });
24
+ afterAll(() => {
25
+ console.error = originalError;
26
+ });
27
+ describe('initial fetch', () => {
28
+ it('should call onStart then onSuccess on successful fetch', async () => {
29
+ const callbacks = createMockCallbacks();
30
+ const mockData = { name: 'test' };
31
+ const mockFetchFn = vi.fn().mockResolvedValue({
32
+ data: mockData,
33
+ cleanup: vi.fn(),
34
+ });
35
+ renderHook(() => useFetchLifecycle({
36
+ fetchFn: mockFetchFn,
37
+ callbacks,
38
+ deps: [],
39
+ }));
40
+ await waitFor(() => {
41
+ expect(callbacks.onStart).toHaveBeenCalledWith({ isRefetch: false });
42
+ expect(callbacks.onSuccess).toHaveBeenCalledWith(mockData, {
43
+ isRefetch: false,
44
+ });
45
+ expect(callbacks.onError).not.toHaveBeenCalled();
46
+ });
47
+ });
48
+ it('should call onError when fetch rejects with an Error', async () => {
49
+ const callbacks = createMockCallbacks();
50
+ const error = new Error('Network failure');
51
+ const mockFetchFn = vi.fn().mockRejectedValue(error);
52
+ renderHook(() => useFetchLifecycle({
53
+ fetchFn: mockFetchFn,
54
+ callbacks,
55
+ deps: [],
56
+ }));
57
+ await waitFor(() => {
58
+ expect(callbacks.onError).toHaveBeenCalledWith(error, {
59
+ isRefetch: false,
60
+ });
61
+ expect(callbacks.onSuccess).not.toHaveBeenCalled();
62
+ });
63
+ });
64
+ it('should use defaultErrorMessage for non-Error rejections', async () => {
65
+ const callbacks = createMockCallbacks();
66
+ const mockFetchFn = vi.fn().mockRejectedValue('string error');
67
+ renderHook(() => useFetchLifecycle({
68
+ fetchFn: mockFetchFn,
69
+ callbacks,
70
+ deps: [],
71
+ defaultErrorMessage: 'Custom error message',
72
+ }));
73
+ await waitFor(() => {
74
+ expect(callbacks.onError).toHaveBeenCalledTimes(1);
75
+ const [error] = callbacks.onError.mock.calls[0];
76
+ expect(error).toBeInstanceOf(Error);
77
+ expect(error.message).toBe('Custom error message');
78
+ });
79
+ });
80
+ it('should pass signal with isRefetch=false to fetchFn during initial fetch', async () => {
81
+ const callbacks = createMockCallbacks();
82
+ let capturedSignal;
83
+ const mockFetchFn = vi.fn().mockImplementation((signal) => {
84
+ capturedSignal = signal;
85
+ return Promise.resolve({ data: 'test' });
86
+ });
87
+ renderHook(() => useFetchLifecycle({
88
+ fetchFn: mockFetchFn,
89
+ callbacks,
90
+ deps: [],
91
+ }));
92
+ await waitFor(() => {
93
+ expect(capturedSignal).toBeDefined();
94
+ expect(capturedSignal?.isRefetch).toBe(false);
95
+ expect(capturedSignal?.cancelled).toBe(false);
96
+ });
97
+ });
98
+ });
99
+ describe('cleanup', () => {
100
+ it('should call cleanup function when component unmounts', async () => {
101
+ const callbacks = createMockCallbacks();
102
+ const mockCleanup = vi.fn();
103
+ const mockFetchFn = vi.fn().mockResolvedValue({
104
+ data: 'test',
105
+ cleanup: mockCleanup,
106
+ });
107
+ const { unmount } = renderHook(() => useFetchLifecycle({
108
+ fetchFn: mockFetchFn,
109
+ callbacks,
110
+ deps: [],
111
+ }));
112
+ await waitFor(() => {
113
+ expect(callbacks.onSuccess).toHaveBeenCalled();
114
+ });
115
+ unmount();
116
+ expect(mockCleanup).toHaveBeenCalledTimes(1);
117
+ });
118
+ it('should call cleanup when deps change and start new fetch', async () => {
119
+ const callbacks = createMockCallbacks();
120
+ const mockCleanup1 = vi.fn();
121
+ const mockCleanup2 = vi.fn();
122
+ const mockFetchFn = vi
123
+ .fn()
124
+ .mockResolvedValueOnce({ data: 'first', cleanup: mockCleanup1 })
125
+ .mockResolvedValueOnce({ data: 'second', cleanup: mockCleanup2 });
126
+ const { rerender } = renderHook(({ dep }) => useFetchLifecycle({
127
+ fetchFn: mockFetchFn,
128
+ callbacks,
129
+ deps: [dep],
130
+ }), { initialProps: { dep: 'a' } });
131
+ await waitFor(() => {
132
+ expect(mockFetchFn).toHaveBeenCalledTimes(1);
133
+ });
134
+ rerender({ dep: 'b' });
135
+ await waitFor(() => {
136
+ expect(mockFetchFn).toHaveBeenCalledTimes(2);
137
+ expect(mockCleanup1).toHaveBeenCalledTimes(1);
138
+ });
139
+ });
140
+ it('should not call onFetchSuccess when fetch resolves after cancellation', async () => {
141
+ const callbacks = createMockCallbacks();
142
+ let resolveFirst = () => { };
143
+ const firstFetchPromise = new Promise((resolve) => {
144
+ resolveFirst = resolve;
145
+ });
146
+ const mockFetchFn = vi
147
+ .fn()
148
+ .mockImplementationOnce(() => firstFetchPromise)
149
+ .mockResolvedValueOnce({ data: 'second' });
150
+ const { rerender } = renderHook(({ dep }) => useFetchLifecycle({
151
+ fetchFn: mockFetchFn,
152
+ callbacks,
153
+ deps: [dep],
154
+ }), { initialProps: { dep: 'a' } });
155
+ // Change deps before first fetch resolves
156
+ rerender({ dep: 'b' });
157
+ // Now resolve the first fetch
158
+ resolveFirst({ data: 'first', cleanup: vi.fn() });
159
+ await waitFor(() => {
160
+ expect(mockFetchFn).toHaveBeenCalledTimes(2);
161
+ });
162
+ // onSuccess should have been called with 'second' (from the second fetch), not 'first'
163
+ // onStart is called twice (once per fetch)
164
+ expect(callbacks.onStart).toHaveBeenCalledTimes(2);
165
+ expect(callbacks.onSuccess).toHaveBeenCalledWith('second', {
166
+ isRefetch: false,
167
+ });
168
+ // It should NOT have been called with 'first' (cancelled)
169
+ expect(callbacks.onSuccess).not.toHaveBeenCalledWith('first', expect.anything());
170
+ });
171
+ });
172
+ describe('refetch', () => {
173
+ it('should call onStart and onSuccess with isRefetch=true on successful refetch', async () => {
174
+ const callbacks = createMockCallbacks();
175
+ const mockFetchFn = vi
176
+ .fn()
177
+ .mockResolvedValueOnce({ data: 'initial', cleanup: vi.fn() })
178
+ .mockResolvedValueOnce({ data: 'refetched', cleanup: vi.fn() });
179
+ const { result } = renderHook(() => useFetchLifecycle({
180
+ fetchFn: mockFetchFn,
181
+ callbacks,
182
+ deps: [],
183
+ }));
184
+ await waitFor(() => {
185
+ expect(callbacks.onSuccess).toHaveBeenCalled();
186
+ });
187
+ await result.current.refetch();
188
+ // onStart called twice: once for fetch, once for refetch
189
+ expect(callbacks.onStart).toHaveBeenCalledTimes(2);
190
+ expect(callbacks.onStart).toHaveBeenLastCalledWith({ isRefetch: true });
191
+ expect(callbacks.onSuccess).toHaveBeenCalledWith('refetched', {
192
+ isRefetch: true,
193
+ });
194
+ });
195
+ it('should call onError with isRefetch=true when refetch fails', async () => {
196
+ const callbacks = createMockCallbacks();
197
+ const error = new Error('Refetch failed');
198
+ const mockFetchFn = vi
199
+ .fn()
200
+ .mockResolvedValueOnce({ data: 'initial', cleanup: vi.fn() })
201
+ .mockRejectedValueOnce(error);
202
+ const { result } = renderHook(() => useFetchLifecycle({
203
+ fetchFn: mockFetchFn,
204
+ callbacks,
205
+ deps: [],
206
+ }));
207
+ await waitFor(() => {
208
+ expect(callbacks.onSuccess).toHaveBeenCalled();
209
+ });
210
+ await result.current.refetch();
211
+ expect(callbacks.onError).toHaveBeenCalledWith(error, {
212
+ isRefetch: true,
213
+ });
214
+ });
215
+ it('should pass signal with isRefetch=true to fetchFn during refetch', async () => {
216
+ const callbacks = createMockCallbacks();
217
+ const capturedSignals = [];
218
+ const mockFetchFn = vi.fn().mockImplementation((signal) => {
219
+ capturedSignals.push({ ...signal });
220
+ return Promise.resolve({ data: 'test', cleanup: vi.fn() });
221
+ });
222
+ const { result } = renderHook(() => useFetchLifecycle({
223
+ fetchFn: mockFetchFn,
224
+ callbacks,
225
+ deps: [],
226
+ }));
227
+ await waitFor(() => {
228
+ expect(callbacks.onSuccess).toHaveBeenCalled();
229
+ });
230
+ await result.current.refetch();
231
+ expect(capturedSignals).toHaveLength(2);
232
+ expect(capturedSignals[0].isRefetch).toBe(false); // initial fetch
233
+ expect(capturedSignals[1].isRefetch).toBe(true); // refetch
234
+ });
235
+ it('should cancel in-flight refetch when called again', async () => {
236
+ const callbacks = createMockCallbacks();
237
+ const firstRefetchCleanup = vi.fn();
238
+ const secondRefetchCleanup = vi.fn();
239
+ const mockFetchFn = vi
240
+ .fn()
241
+ .mockResolvedValueOnce({ data: 'initial', cleanup: vi.fn() })
242
+ .mockResolvedValueOnce({
243
+ data: 'first-refetch',
244
+ cleanup: firstRefetchCleanup,
245
+ })
246
+ .mockResolvedValueOnce({
247
+ data: 'second-refetch',
248
+ cleanup: secondRefetchCleanup,
249
+ });
250
+ const { result } = renderHook(() => useFetchLifecycle({
251
+ fetchFn: mockFetchFn,
252
+ callbacks,
253
+ deps: [],
254
+ }));
255
+ await waitFor(() => {
256
+ expect(callbacks.onSuccess).toHaveBeenCalled();
257
+ });
258
+ // Call refetch twice rapidly
259
+ const first = result.current.refetch();
260
+ const second = result.current.refetch();
261
+ await Promise.all([first, second]);
262
+ // Second refetch should win
263
+ expect(callbacks.onSuccess).toHaveBeenCalledWith('second-refetch', {
264
+ isRefetch: true,
265
+ });
266
+ // First refetch's cleanup should have been called (cancelled)
267
+ expect(firstRefetchCleanup).toHaveBeenCalledTimes(1);
268
+ // Second refetch's cleanup should NOT be called yet
269
+ expect(secondRefetchCleanup).not.toHaveBeenCalled();
270
+ });
271
+ it('should clean up previous refetch subscription when starting new refetch', async () => {
272
+ const callbacks = createMockCallbacks();
273
+ const firstRefetchCleanup = vi.fn();
274
+ const mockFetchFn = vi
275
+ .fn()
276
+ .mockResolvedValueOnce({ data: 'initial', cleanup: vi.fn() })
277
+ .mockResolvedValueOnce({
278
+ data: 'first-refetch',
279
+ cleanup: firstRefetchCleanup,
280
+ })
281
+ .mockResolvedValueOnce({
282
+ data: 'second-refetch',
283
+ cleanup: vi.fn(),
284
+ });
285
+ const { result } = renderHook(() => useFetchLifecycle({
286
+ fetchFn: mockFetchFn,
287
+ callbacks,
288
+ deps: [],
289
+ }));
290
+ await waitFor(() => {
291
+ expect(callbacks.onSuccess).toHaveBeenCalled();
292
+ });
293
+ // First refetch completes normally
294
+ await result.current.refetch();
295
+ expect(firstRefetchCleanup).not.toHaveBeenCalled();
296
+ // Second refetch should clean up first refetch's subscription
297
+ await result.current.refetch();
298
+ expect(firstRefetchCleanup).toHaveBeenCalledTimes(1);
299
+ });
300
+ it('should clean up refetch subscription on unmount', async () => {
301
+ const callbacks = createMockCallbacks();
302
+ const refetchCleanup = vi.fn();
303
+ const mockFetchFn = vi
304
+ .fn()
305
+ .mockResolvedValueOnce({ data: 'initial', cleanup: vi.fn() })
306
+ .mockResolvedValueOnce({
307
+ data: 'refetched',
308
+ cleanup: refetchCleanup,
309
+ });
310
+ const { result, unmount } = renderHook(() => useFetchLifecycle({
311
+ fetchFn: mockFetchFn,
312
+ callbacks,
313
+ deps: [],
314
+ }));
315
+ await waitFor(() => {
316
+ expect(callbacks.onSuccess).toHaveBeenCalled();
317
+ });
318
+ await result.current.refetch();
319
+ expect(refetchCleanup).not.toHaveBeenCalled();
320
+ unmount();
321
+ expect(refetchCleanup).toHaveBeenCalledTimes(1);
322
+ });
323
+ });
324
+ });
@@ -0,0 +1,17 @@
1
+ import { renderHook } from '@testing-library/react';
2
+ import { useStableValue } from "../../internal/hook-utils.js";
3
+ describe('useStableValue', () => {
4
+ it('should preserve reference for deeply equal objects', () => {
5
+ const { result, rerender } = renderHook(({ value }) => useStableValue(value), { initialProps: { value: { a: 1, b: [2, 3] } } });
6
+ const firstRef = result.current;
7
+ rerender({ value: { a: 1, b: [2, 3] } });
8
+ expect(result.current).toBe(firstRef);
9
+ });
10
+ it('should return a new reference when content changes', () => {
11
+ const { result, rerender } = renderHook(({ value }) => useStableValue(value), { initialProps: { value: { a: 1 } } });
12
+ const firstRef = result.current;
13
+ rerender({ value: { a: 2 } });
14
+ expect(result.current).not.toBe(firstRef);
15
+ expect(result.current).toEqual({ a: 2 });
16
+ });
17
+ });