@hubspot/ui-extensions 0.12.4 → 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.
- package/dist/__tests__/crm/hooks/useAssociations.spec.js +28 -0
- package/dist/__tests__/hooks/useDebounce.spec.js +123 -0
- package/dist/__tests__/hooks/utils/useFetchLifecycle.spec.js +324 -0
- package/dist/__tests__/internal/hook-utils.spec.js +17 -0
- package/dist/__tests__/test-d/extension-points.test-d.js +1 -0
- package/dist/crm/hooks/useAssociations.d.ts +2 -2
- package/dist/crm/hooks/useAssociations.js +44 -137
- package/dist/crm/hooks/useCrmProperties.js +29 -125
- package/dist/hooks/useDebounce.d.ts +19 -0
- package/dist/hooks/useDebounce.js +32 -0
- package/dist/hooks/utils/useFetchLifecycle.d.ts +35 -0
- package/dist/hooks/utils/useFetchLifecycle.js +103 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/internal/hook-utils.d.ts +6 -0
- package/dist/internal/hook-utils.js +16 -1
- package/dist/{experimental/pages → pages}/components/page-routes.d.ts +1 -2
- package/dist/{experimental/pages → pages}/components/page-routes.js +0 -4
- package/dist/{experimental/pages → pages}/create-page-router.d.ts +1 -3
- package/dist/{experimental/pages → pages}/create-page-router.js +1 -3
- package/dist/{experimental/pages → pages}/create-page-router.test.js +2 -2
- package/dist/{experimental/pages → pages}/hooks.d.ts +0 -1
- package/dist/{experimental/pages → pages}/hooks.js +0 -1
- package/dist/pages/index.d.ts +6 -1
- package/dist/pages/index.js +4 -1
- package/dist/{experimental/pages → pages}/internal/page-router-internal-types.d.ts +1 -1
- package/dist/pages/internal/useAppPageLocation.d.ts +1 -0
- package/dist/{experimental/pages → pages}/internal/useAppPageLocation.js +2 -2
- package/dist/{experimental/pages → pages}/types.d.ts +1 -2
- package/dist/shared/types/actions.d.ts +12 -2
- package/dist/shared/types/context.d.ts +5 -0
- package/dist/shared/types/crm.d.ts +4 -0
- package/dist/shared/types/extension-points.d.ts +9 -3
- package/dist/shared/types/extension-points.js +1 -0
- package/dist/shared/types/shared.d.ts +8 -0
- package/dist/testing/__tests__/createRenderer.spec.js +1 -1
- package/dist/testing/internal/mocks/index.d.ts +1 -1
- package/dist/testing/internal/mocks/mock-extension-point-api.js +21 -1
- package/dist/testing/types.d.ts +1 -1
- package/package.json +2 -2
- package/dist/experimental/pages/index.d.ts +0 -6
- package/dist/experimental/pages/index.js +0 -4
- package/dist/experimental/pages/internal/useAppPageLocation.d.ts +0 -1
- package/dist/shared/types/pages/app-pages-types.d.ts +0 -75
- package/dist/shared/types/pages/components/page-routes.d.ts +0 -115
- package/dist/shared/types/pages/index.d.ts +0 -1
- package/dist/shared/types/pages/index.js +0 -1
- package/dist/shared/types/pages.d.ts +0 -1
- /package/dist/{experimental/pages/create-page-router.test.d.ts → __tests__/hooks/useDebounce.spec.d.ts} +0 -0
- /package/dist/{experimental/pages/internal/trie-router.test.d.ts → __tests__/hooks/utils/useFetchLifecycle.spec.d.ts} +0 -0
- /package/dist/{experimental/pages/types.js → __tests__/internal/hook-utils.spec.d.ts} +0 -0
- /package/dist/{experimental/pages → pages}/components/index.d.ts +0 -0
- /package/dist/{experimental/pages → pages}/components/index.js +0 -0
- /package/dist/{shared/types/pages.js → pages/create-page-router.test.d.ts} +0 -0
- /package/dist/{experimental/pages → pages}/internal/app-page-route-context.d.ts +0 -0
- /package/dist/{experimental/pages → pages}/internal/app-page-route-context.js +0 -0
- /package/dist/{experimental/pages → pages}/internal/convert-page-routes-react-elements.d.ts +0 -0
- /package/dist/{experimental/pages → pages}/internal/convert-page-routes-react-elements.js +0 -0
- /package/dist/{experimental/pages → pages}/internal/page-router-internal-types.js +0 -0
- /package/dist/{experimental/pages → pages}/internal/trie-router.d.ts +0 -0
- /package/dist/{experimental/pages → pages}/internal/trie-router.js +0 -0
- /package/dist/{shared/types/pages/app-pages-types.js → pages/internal/trie-router.test.d.ts} +0 -0
- /package/dist/{experimental/pages → pages}/internal/trie-router.test.js +0 -0
- /package/dist/{shared/types/pages/components/page-routes.js → pages/types.js} +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: {
|
|
@@ -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
|
+
});
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { type PaginationResult } from '../../utils/pagination.ts';
|
|
2
2
|
import type { AssociationResult, FetchAssociationsRequest } from '../utils/fetchAssociations.ts';
|
|
3
3
|
import { type FetchCrmPropertiesOptions } from '../utils/fetchCrmProperties.ts';
|
|
4
|
-
export
|
|
4
|
+
export type UseAssociationsOptions = {
|
|
5
5
|
propertiesToFormat?: 'all' | string[];
|
|
6
6
|
formattingOptions?: FetchCrmPropertiesOptions['formattingOptions'];
|
|
7
|
-
}
|
|
7
|
+
};
|
|
8
8
|
export interface UseAssociationsResult {
|
|
9
9
|
results: AssociationResult[];
|
|
10
10
|
error: Error | null;
|