@hubspot/ui-extensions 0.12.3 → 0.13.0

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 (84) hide show
  1. package/dist/__tests__/crm/hooks/useAssociations.spec.js +28 -0
  2. package/dist/__tests__/crm/utils/fetchAssociations.spec.js +2 -1
  3. package/dist/__tests__/hooks/useDebounce.spec.js +123 -0
  4. package/dist/__tests__/hooks/utils/useFetchLifecycle.spec.js +324 -0
  5. package/dist/__tests__/internal/hook-utils.spec.d.ts +1 -0
  6. package/dist/__tests__/internal/hook-utils.spec.js +17 -0
  7. package/dist/__tests__/test-d/extension-points.test-d.js +1 -0
  8. package/dist/crm/hooks/useAssociations.d.ts +4 -12
  9. package/dist/crm/hooks/useAssociations.js +46 -138
  10. package/dist/crm/hooks/useCrmProperties.js +29 -125
  11. package/dist/crm/utils/fetchAssociations.d.ts +0 -8
  12. package/dist/crm/utils/fetchAssociations.js +0 -10
  13. package/dist/hooks/useDebounce.d.ts +19 -0
  14. package/dist/hooks/useDebounce.js +32 -0
  15. package/dist/hooks/utils/useFetchLifecycle.d.ts +35 -0
  16. package/dist/hooks/utils/useFetchLifecycle.js +103 -0
  17. package/dist/index.d.ts +1 -0
  18. package/dist/index.js +1 -0
  19. package/dist/internal/hook-utils.d.ts +13 -6
  20. package/dist/internal/hook-utils.js +24 -10
  21. package/dist/pages/components/index.d.ts +1 -0
  22. package/dist/pages/components/index.js +1 -0
  23. package/dist/{shared/types/pages → pages}/components/page-routes.d.ts +6 -39
  24. package/dist/pages/components/page-routes.js +62 -0
  25. package/dist/pages/create-page-router.d.ts +33 -0
  26. package/dist/pages/create-page-router.js +121 -0
  27. package/dist/pages/create-page-router.test.d.ts +1 -0
  28. package/dist/pages/create-page-router.test.js +296 -0
  29. package/dist/pages/hooks.d.ts +7 -0
  30. package/dist/pages/hooks.js +14 -0
  31. package/dist/pages/index.d.ts +6 -0
  32. package/dist/pages/index.js +4 -0
  33. package/dist/pages/internal/app-page-route-context.d.ts +16 -0
  34. package/dist/pages/internal/app-page-route-context.js +12 -0
  35. package/dist/pages/internal/convert-page-routes-react-elements.d.ts +9 -0
  36. package/dist/pages/internal/convert-page-routes-react-elements.js +138 -0
  37. package/dist/pages/internal/page-router-internal-types.d.ts +40 -0
  38. package/dist/pages/internal/page-router-internal-types.js +5 -0
  39. package/dist/pages/internal/trie-router.d.ts +31 -0
  40. package/dist/pages/internal/trie-router.js +141 -0
  41. package/dist/pages/internal/trie-router.test.d.ts +1 -0
  42. package/dist/pages/internal/trie-router.test.js +263 -0
  43. package/dist/pages/internal/useAppPageLocation.d.ts +1 -0
  44. package/dist/pages/internal/useAppPageLocation.js +13 -0
  45. package/dist/pages/types.d.ts +28 -0
  46. package/dist/pages/types.js +1 -0
  47. package/dist/shared/remoteComponents.d.ts +24 -0
  48. package/dist/shared/remoteComponents.js +28 -0
  49. package/dist/shared/types/actions.d.ts +12 -2
  50. package/dist/shared/types/components/button.d.ts +2 -2
  51. package/dist/shared/types/components/image.d.ts +2 -2
  52. package/dist/shared/types/components/inputs.d.ts +7 -1
  53. package/dist/shared/types/components/link.d.ts +2 -2
  54. package/dist/shared/types/context.d.ts +5 -0
  55. package/dist/shared/types/crm.d.ts +4 -0
  56. package/dist/shared/types/extension-points.d.ts +9 -3
  57. package/dist/shared/types/extension-points.js +1 -0
  58. package/dist/shared/types/pages/components/index.d.ts +3 -1
  59. package/dist/shared/types/pages/components/page-breadcrumbs.d.ts +34 -0
  60. package/dist/shared/types/pages/components/page-breadcrumbs.js +1 -0
  61. package/dist/shared/types/pages/components/page-header.d.ts +82 -0
  62. package/dist/shared/types/pages/components/page-header.js +1 -0
  63. package/dist/shared/types/pages/components/page-link.d.ts +4 -2
  64. package/dist/shared/types/pages/components/page-title.d.ts +12 -0
  65. package/dist/shared/types/pages/components/page-title.js +1 -0
  66. package/dist/shared/types/shared.d.ts +8 -0
  67. package/dist/shared/types/worker-globals.d.ts +3 -12
  68. package/dist/testing/__tests__/createRenderer.spec.js +1 -1
  69. package/dist/testing/internal/mocks/index.d.ts +14 -6
  70. package/dist/testing/internal/mocks/index.js +19 -5
  71. package/dist/testing/internal/mocks/mock-app-page-location.d.ts +7 -0
  72. package/dist/testing/internal/mocks/mock-app-page-location.js +35 -0
  73. package/dist/testing/internal/mocks/mock-extension-point-api.js +21 -1
  74. package/dist/testing/internal/mocks/mock-hooks.js +12 -7
  75. package/dist/testing/render.js +7 -6
  76. package/dist/testing/types.d.ts +19 -3
  77. package/dist/utils/pagination.d.ts +17 -0
  78. package/dist/utils/pagination.js +10 -0
  79. package/package.json +2 -2
  80. package/dist/experimental/pages/index.d.ts +0 -4
  81. package/dist/experimental/pages/index.js +0 -3
  82. package/dist/shared/types/pages/app-pages-types.d.ts +0 -75
  83. /package/dist/{shared/types/pages/app-pages-types.js → __tests__/hooks/useDebounce.spec.d.ts} +0 -0
  84. /package/dist/{shared/types/pages/components/page-routes.js → __tests__/hooks/utils/useFetchLifecycle.spec.d.ts} +0 -0
@@ -284,6 +284,34 @@ describe('useAssociations with Pagination', () => {
284
284
  expect(result.current.pagination.hasPreviousPage).toBe(false);
285
285
  });
286
286
  });
287
+ it('should not reset if already on first page', async () => {
288
+ mockFetchAssociations.mockResolvedValueOnce({
289
+ data: {
290
+ results: [{ toObjectId: 1, associationTypes: [], properties: {} }],
291
+ hasMore: true,
292
+ nextOffset: 10,
293
+ },
294
+ cleanup: vi.fn(),
295
+ });
296
+ const { result } = renderHook(() => useAssociations({
297
+ toObjectType: '0-1',
298
+ pageLength: 10,
299
+ }));
300
+ // Wait for initial load
301
+ await waitFor(() => {
302
+ expect(result.current.pagination.currentPage).toBe(1);
303
+ });
304
+ // Try to reset while on first page
305
+ result.current.pagination.reset();
306
+ await waitFor(() => {
307
+ expect(result.current.pagination.currentPage).toBe(1);
308
+ });
309
+ // Verify results were not wiped and API was not called again
310
+ expect(result.current.results).toEqual([
311
+ { toObjectId: 1, associationTypes: [], properties: {} },
312
+ ]);
313
+ expect(mockFetchAssociations).toHaveBeenCalledTimes(1);
314
+ });
287
315
  it('should not allow navigation beyond boundaries', async () => {
288
316
  mockFetchAssociations.mockResolvedValue({
289
317
  data: {
@@ -1,5 +1,6 @@
1
1
  import { vi } from 'vitest';
2
- import { calculatePaginationFlags, fetchAssociations, } from "../../../crm/utils/fetchAssociations.js";
2
+ import { fetchAssociations } from "../../../crm/utils/fetchAssociations.js";
3
+ import { calculatePaginationFlags } from "../../../utils/pagination.js";
3
4
  // Set up the mock before importing the module
4
5
  const mockFetchAssociations = vi.fn();
5
6
  const mockSelf = {
@@ -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 @@
1
+ export {};
@@ -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
+ });
@@ -5,6 +5,7 @@ expectAssignable({
5
5
  addAlert: () => { },
6
6
  copyTextToClipboard: async () => { },
7
7
  closeOverlay: () => { },
8
+ navigateToPage: () => { },
8
9
  reloadPage: () => { },
9
10
  openIframeModal: () => { },
10
11
  },
@@ -1,24 +1,16 @@
1
+ import { type PaginationResult } from '../../utils/pagination.ts';
1
2
  import type { AssociationResult, FetchAssociationsRequest } from '../utils/fetchAssociations.ts';
2
3
  import { type FetchCrmPropertiesOptions } from '../utils/fetchCrmProperties.ts';
3
- export interface UseAssociationsOptions {
4
+ export type UseAssociationsOptions = {
4
5
  propertiesToFormat?: 'all' | string[];
5
6
  formattingOptions?: FetchCrmPropertiesOptions['formattingOptions'];
6
- }
7
- export interface UseAssociationsPagination {
8
- hasNextPage: boolean;
9
- hasPreviousPage: boolean;
10
- currentPage: number;
11
- pageSize: number;
12
- nextPage: () => void;
13
- previousPage: () => void;
14
- reset: () => void;
15
- }
7
+ };
16
8
  export interface UseAssociationsResult {
17
9
  results: AssociationResult[];
18
10
  error: Error | null;
19
11
  isLoading: boolean;
20
12
  isRefetching: boolean;
21
- pagination: UseAssociationsPagination;
13
+ pagination: PaginationResult;
22
14
  refetch: () => Promise<void>;
23
15
  }
24
16
  /**