@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.
Files changed (64) hide show
  1. package/dist/__tests__/crm/hooks/useAssociations.spec.js +28 -0
  2. package/dist/__tests__/hooks/useDebounce.spec.js +123 -0
  3. package/dist/__tests__/hooks/utils/useFetchLifecycle.spec.js +324 -0
  4. package/dist/__tests__/internal/hook-utils.spec.js +17 -0
  5. package/dist/__tests__/test-d/extension-points.test-d.js +1 -0
  6. package/dist/crm/hooks/useAssociations.d.ts +2 -2
  7. package/dist/crm/hooks/useAssociations.js +44 -137
  8. package/dist/crm/hooks/useCrmProperties.js +29 -125
  9. package/dist/hooks/useDebounce.d.ts +19 -0
  10. package/dist/hooks/useDebounce.js +32 -0
  11. package/dist/hooks/utils/useFetchLifecycle.d.ts +35 -0
  12. package/dist/hooks/utils/useFetchLifecycle.js +103 -0
  13. package/dist/index.d.ts +1 -0
  14. package/dist/index.js +1 -0
  15. package/dist/internal/hook-utils.d.ts +6 -0
  16. package/dist/internal/hook-utils.js +16 -1
  17. package/dist/{experimental/pages → pages}/components/page-routes.d.ts +1 -2
  18. package/dist/{experimental/pages → pages}/components/page-routes.js +0 -4
  19. package/dist/{experimental/pages → pages}/create-page-router.d.ts +1 -3
  20. package/dist/{experimental/pages → pages}/create-page-router.js +1 -3
  21. package/dist/{experimental/pages → pages}/create-page-router.test.js +2 -2
  22. package/dist/{experimental/pages → pages}/hooks.d.ts +0 -1
  23. package/dist/{experimental/pages → pages}/hooks.js +0 -1
  24. package/dist/pages/index.d.ts +6 -1
  25. package/dist/pages/index.js +4 -1
  26. package/dist/{experimental/pages → pages}/internal/page-router-internal-types.d.ts +1 -1
  27. package/dist/pages/internal/useAppPageLocation.d.ts +1 -0
  28. package/dist/{experimental/pages → pages}/internal/useAppPageLocation.js +2 -2
  29. package/dist/{experimental/pages → pages}/types.d.ts +1 -2
  30. package/dist/shared/types/actions.d.ts +12 -2
  31. package/dist/shared/types/context.d.ts +5 -0
  32. package/dist/shared/types/crm.d.ts +4 -0
  33. package/dist/shared/types/extension-points.d.ts +9 -3
  34. package/dist/shared/types/extension-points.js +1 -0
  35. package/dist/shared/types/shared.d.ts +8 -0
  36. package/dist/testing/__tests__/createRenderer.spec.js +1 -1
  37. package/dist/testing/internal/mocks/index.d.ts +1 -1
  38. package/dist/testing/internal/mocks/mock-extension-point-api.js +21 -1
  39. package/dist/testing/types.d.ts +1 -1
  40. package/package.json +2 -2
  41. package/dist/experimental/pages/index.d.ts +0 -6
  42. package/dist/experimental/pages/index.js +0 -4
  43. package/dist/experimental/pages/internal/useAppPageLocation.d.ts +0 -1
  44. package/dist/shared/types/pages/app-pages-types.d.ts +0 -75
  45. package/dist/shared/types/pages/components/page-routes.d.ts +0 -115
  46. package/dist/shared/types/pages/index.d.ts +0 -1
  47. package/dist/shared/types/pages/index.js +0 -1
  48. package/dist/shared/types/pages.d.ts +0 -1
  49. /package/dist/{experimental/pages/create-page-router.test.d.ts → __tests__/hooks/useDebounce.spec.d.ts} +0 -0
  50. /package/dist/{experimental/pages/internal/trie-router.test.d.ts → __tests__/hooks/utils/useFetchLifecycle.spec.d.ts} +0 -0
  51. /package/dist/{experimental/pages/types.js → __tests__/internal/hook-utils.spec.d.ts} +0 -0
  52. /package/dist/{experimental/pages → pages}/components/index.d.ts +0 -0
  53. /package/dist/{experimental/pages → pages}/components/index.js +0 -0
  54. /package/dist/{shared/types/pages.js → pages/create-page-router.test.d.ts} +0 -0
  55. /package/dist/{experimental/pages → pages}/internal/app-page-route-context.d.ts +0 -0
  56. /package/dist/{experimental/pages → pages}/internal/app-page-route-context.js +0 -0
  57. /package/dist/{experimental/pages → pages}/internal/convert-page-routes-react-elements.d.ts +0 -0
  58. /package/dist/{experimental/pages → pages}/internal/convert-page-routes-react-elements.js +0 -0
  59. /package/dist/{experimental/pages → pages}/internal/page-router-internal-types.js +0 -0
  60. /package/dist/{experimental/pages → pages}/internal/trie-router.d.ts +0 -0
  61. /package/dist/{experimental/pages → pages}/internal/trie-router.js +0 -0
  62. /package/dist/{shared/types/pages/app-pages-types.js → pages/internal/trie-router.test.d.ts} +0 -0
  63. /package/dist/{experimental/pages → pages}/internal/trie-router.test.js +0 -0
  64. /package/dist/{shared/types/pages/components/page-routes.js → pages/types.js} +0 -0
@@ -1,5 +1,6 @@
1
- import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
2
- import { createMockAwareHook } from "../../internal/hook-utils.js";
1
+ import { useCallback, useMemo, useReducer } from 'react';
2
+ import { useFetchLifecycle } from "../../hooks/utils/useFetchLifecycle.js";
3
+ import { createMockAwareHook, useStableValue, } from "../../internal/hook-utils.js";
3
4
  import { calculatePaginationFlags, DEFAULT_PAGE_SIZE, } from "../../utils/pagination.js";
4
5
  import { fetchAssociations } from "../utils/fetchAssociations.js";
5
6
  function createInitialState(pageSize) {
@@ -109,39 +110,8 @@ const DEFAULT_OPTIONS = {};
109
110
  function useAssociationsInternal(config, options = DEFAULT_OPTIONS) {
110
111
  const pageSize = config?.pageLength ?? DEFAULT_PAGE_SIZE;
111
112
  const [state, dispatch] = useReducer(associationsReducer, useMemo(() => createInitialState(pageSize), [pageSize]));
112
- /**
113
- * HOOK OPTIMIZATION:
114
- *
115
- * Create stable references for config and options to prevent unnecessary re-renders and API calls.
116
- * Then, external developers can pass inline objects without worrying about memoization
117
- * We handle the deep equality comparison ourselves, and return the same object reference when content is equivalent.
118
- */
119
- const lastConfigRef = useRef();
120
- const lastConfigKeyRef = useRef();
121
- const lastOptionsRef = useRef();
122
- const lastOptionsKeyRef = useRef();
123
- // Track in-flight refetch to support cancellation
124
- const refetchAbortRef = useRef(null);
125
- // Track refetch cleanup function to prevent memory leaks
126
- const refetchCleanupRef = useRef(null);
127
- const stableConfig = useMemo(() => {
128
- const configKey = JSON.stringify(config);
129
- if (configKey === lastConfigKeyRef.current) {
130
- return lastConfigRef.current;
131
- }
132
- lastConfigKeyRef.current = configKey;
133
- lastConfigRef.current = config;
134
- return config;
135
- }, [config]);
136
- const stableOptions = useMemo(() => {
137
- const optionsKey = JSON.stringify(options);
138
- if (optionsKey === lastOptionsKeyRef.current) {
139
- return lastOptionsRef.current;
140
- }
141
- lastOptionsKeyRef.current = optionsKey;
142
- lastOptionsRef.current = options;
143
- return options;
144
- }, [options]);
113
+ const stableConfig = useStableValue(config);
114
+ const stableOptions = useStableValue(options);
145
115
  // Pagination actions
146
116
  const nextPage = useCallback(() => {
147
117
  const paginationFlags = calculatePaginationFlags(state.currentPage, state.hasMore);
@@ -156,122 +126,59 @@ function useAssociationsInternal(config, options = DEFAULT_OPTIONS) {
156
126
  }
157
127
  }, [state.currentPage, state.hasMore]);
158
128
  const reset = useCallback(() => {
159
- dispatch({ type: 'RESET' });
160
- }, []);
161
- // Fetch the associations
162
- useEffect(() => {
163
- let cancelled = false;
164
- let cleanup = null;
165
- const fetchData = async () => {
166
- try {
167
- dispatch({ type: 'FETCH_START' });
168
- // Build request using current offset token
169
- const request = {
170
- toObjectType: stableConfig?.toObjectType,
171
- properties: stableConfig?.properties,
172
- pageLength: pageSize,
173
- offset: state.currentOffset,
174
- };
175
- const result = await fetchAssociations(request, {
176
- propertiesToFormat: stableOptions.propertiesToFormat,
177
- formattingOptions: stableOptions.formattingOptions,
178
- });
179
- if (!cancelled) {
180
- dispatch({
181
- type: 'FETCH_SUCCESS',
182
- payload: {
183
- results: result.data.results,
184
- hasMore: result.data.hasMore,
185
- nextOffset: result.data.nextOffset,
186
- currentOffset: state.currentOffset,
187
- },
188
- });
189
- cleanup = result.cleanup;
190
- }
191
- }
192
- catch (err) {
193
- if (!cancelled) {
194
- const errorData = err instanceof Error
195
- ? err
196
- : new Error('Failed to fetch associations');
197
- dispatch({ type: 'FETCH_ERROR', payload: errorData });
198
- }
199
- }
200
- };
201
- fetchData();
202
- return () => {
203
- cancelled = true;
204
- // Call cleanup function to release resources
205
- if (cleanup) {
206
- cleanup();
207
- }
208
- // Clean up any active refetch subscription
209
- if (refetchCleanupRef.current) {
210
- refetchCleanupRef.current();
211
- refetchCleanupRef.current = null;
212
- }
213
- };
214
- }, [
215
- stableConfig,
216
- stableOptions,
217
- state.currentPage,
218
- state.currentOffset,
219
- pageSize,
220
- ]);
221
- const refetch = useCallback(async () => {
222
- if (refetchAbortRef.current) {
223
- refetchAbortRef.current.cancelled = true;
224
- }
225
- if (refetchCleanupRef.current) {
226
- refetchCleanupRef.current();
227
- refetchCleanupRef.current = null;
129
+ const paginationFlags = calculatePaginationFlags(state.currentPage, state.hasMore);
130
+ // Only reset to first page if we're not already on the first page
131
+ if (paginationFlags.hasPreviousPage) {
132
+ dispatch({ type: 'RESET' });
228
133
  }
229
- const abortSignal = { cancelled: false };
230
- refetchAbortRef.current = abortSignal;
231
- try {
232
- dispatch({ type: 'REFETCH_START' });
233
- // Build request using current offset token to refetch current page
134
+ }, [state.currentPage, state.hasMore]);
135
+ const { refetch } = useFetchLifecycle({
136
+ fetchFn: () => {
234
137
  const request = {
235
138
  toObjectType: stableConfig?.toObjectType,
236
139
  properties: stableConfig?.properties,
237
140
  pageLength: pageSize,
238
141
  offset: state.currentOffset,
239
142
  };
240
- const result = await fetchAssociations(request, {
143
+ return fetchAssociations(request, {
241
144
  propertiesToFormat: stableOptions.propertiesToFormat,
242
145
  formattingOptions: stableOptions.formattingOptions,
243
146
  });
244
- if (!abortSignal.cancelled) {
245
- dispatch({
147
+ },
148
+ callbacks: {
149
+ onStart: ({ isRefetch }) => dispatch({ type: isRefetch ? 'REFETCH_START' : 'FETCH_START' }),
150
+ onSuccess: (data, { isRefetch }) => dispatch(isRefetch
151
+ ? {
246
152
  type: 'REFETCH_SUCCESS',
247
153
  payload: {
248
- results: result.data.results,
249
- hasMore: result.data.hasMore,
250
- nextOffset: result.data.nextOffset,
154
+ results: data.results,
155
+ hasMore: data.hasMore,
156
+ nextOffset: data.nextOffset,
251
157
  },
252
- });
253
- refetchCleanupRef.current = result.cleanup;
254
- }
255
- else {
256
- if (result.cleanup) {
257
- result.cleanup();
258
158
  }
259
- }
260
- }
261
- catch (err) {
262
- if (!abortSignal.cancelled) {
263
- const errorData = err instanceof Error
264
- ? err
265
- : new Error('Failed to refetch associations');
266
- dispatch({ type: 'REFETCH_ERROR', payload: errorData });
267
- }
268
- }
269
- finally {
270
- if (refetchAbortRef.current === abortSignal) {
271
- refetchAbortRef.current = null;
272
- }
273
- }
274
- }, [stableConfig, stableOptions, state.currentOffset, pageSize]);
159
+ : {
160
+ type: 'FETCH_SUCCESS',
161
+ payload: {
162
+ results: data.results,
163
+ hasMore: data.hasMore,
164
+ nextOffset: data.nextOffset,
165
+ currentOffset: state.currentOffset,
166
+ },
167
+ }),
168
+ onError: (error, { isRefetch }) => dispatch({
169
+ type: isRefetch ? 'REFETCH_ERROR' : 'FETCH_ERROR',
170
+ payload: error,
171
+ }),
172
+ },
173
+ deps: [
174
+ stableConfig,
175
+ stableOptions,
176
+ state.currentPage,
177
+ state.currentOffset,
178
+ pageSize,
179
+ ],
180
+ defaultErrorMessage: 'Failed to fetch associations',
181
+ });
275
182
  // Calculate pagination flags
276
183
  const paginationFlags = calculatePaginationFlags(state.currentPage, state.hasMore);
277
184
  return {
@@ -1,5 +1,6 @@
1
- import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
2
- import { createMockAwareHook } from "../../internal/hook-utils.js";
1
+ import { useMemo, useReducer } from 'react';
2
+ import { useFetchLifecycle } from "../../hooks/utils/useFetchLifecycle.js";
3
+ import { createMockAwareHook, useStableValue, } from "../../internal/hook-utils.js";
3
4
  import { fetchCrmProperties } from "../utils/fetchCrmProperties.js";
4
5
  const initialState = {
5
6
  properties: {},
@@ -58,130 +59,33 @@ const DEFAULT_OPTIONS = {};
58
59
  */
59
60
  function useCrmPropertiesInternal(propertyNames, options = DEFAULT_OPTIONS) {
60
61
  const [state, dispatch] = useReducer(crmPropertiesReducer, initialState);
61
- /**
62
- * HOOK OPTIMIZATION:
63
- *
64
- * Create stable references for propertyNames and options to prevent unnecessary re-renders and API calls.
65
- * Then, external developers can pass inline arrays/objects without worrying about memoization
66
- * We handle the deep equality comparison ourselves, and return the same object reference when content is equivalent.
67
- */
68
- const lastPropertyNamesRef = useRef();
69
- const lastPropertyNamesKeyRef = useRef();
70
- const lastOptionsRef = useRef();
71
- const lastOptionsKeyRef = useRef();
72
- // Track in-flight refetch to support cancellation
73
- const refetchAbortRef = useRef(null);
74
- // Track refetch cleanup function to prevent memory leaks
75
- const refetchCleanupRef = useRef(null);
76
- const stablePropertyNames = useMemo(() => {
77
- if (!Array.isArray(propertyNames)) {
78
- return propertyNames;
79
- }
80
- const sortedNames = [...propertyNames].sort();
81
- const propertyNamesKey = JSON.stringify(sortedNames);
82
- if (propertyNamesKey === lastPropertyNamesKeyRef.current) {
83
- return lastPropertyNamesRef.current;
84
- }
85
- lastPropertyNamesKeyRef.current = propertyNamesKey;
86
- lastPropertyNamesRef.current = sortedNames;
87
- return sortedNames;
88
- }, [propertyNames]);
89
- const stableOptions = useMemo(() => {
90
- const optionsKey = JSON.stringify(options);
91
- if (optionsKey === lastOptionsKeyRef.current) {
92
- return lastOptionsRef.current;
93
- }
94
- lastOptionsKeyRef.current = optionsKey;
95
- lastOptionsRef.current = options;
96
- return options;
97
- }, [options]);
98
- // Fetch the properties
99
- useEffect(() => {
100
- let cancelled = false;
101
- let cleanup = null;
102
- const fetchData = async () => {
103
- try {
104
- dispatch({ type: 'FETCH_START' });
105
- const result = await fetchCrmProperties(stablePropertyNames, (data) => {
106
- if (!cancelled) {
107
- dispatch({ type: 'FETCH_SUCCESS', payload: data });
108
- }
109
- }, stableOptions);
110
- if (!cancelled) {
111
- dispatch({ type: 'FETCH_SUCCESS', payload: result.data });
112
- cleanup = result.cleanup;
113
- }
62
+ // Sort property names so that order doesn't matter for cache keys
63
+ const sortedPropertyNames = useMemo(() => Array.isArray(propertyNames) ? [...propertyNames].sort() : propertyNames, [propertyNames]);
64
+ const stablePropertyNames = useStableValue(sortedPropertyNames);
65
+ const stableOptions = useStableValue(options);
66
+ const { refetch } = useFetchLifecycle({
67
+ fetchFn: (signal) => fetchCrmProperties(stablePropertyNames, (data) => {
68
+ if (!signal.cancelled) {
69
+ dispatch({
70
+ type: signal.isRefetch ? 'REFETCH_SUCCESS' : 'FETCH_SUCCESS',
71
+ payload: data,
72
+ });
114
73
  }
115
- catch (err) {
116
- if (!cancelled) {
117
- const errorData = err instanceof Error
118
- ? err
119
- : new Error('Failed to fetch CRM properties');
120
- dispatch({ type: 'FETCH_ERROR', payload: errorData });
121
- }
122
- }
123
- };
124
- fetchData();
125
- return () => {
126
- cancelled = true;
127
- // Call cleanup function to release RPC resources
128
- if (cleanup) {
129
- cleanup();
130
- }
131
- // Clean up any active refetch subscription
132
- if (refetchCleanupRef.current) {
133
- refetchCleanupRef.current();
134
- refetchCleanupRef.current = null;
135
- }
136
- };
137
- }, [stablePropertyNames, stableOptions]);
138
- const refetch = useCallback(async () => {
139
- // Cancel any in-flight refetch
140
- if (refetchAbortRef.current) {
141
- refetchAbortRef.current.cancelled = true;
142
- }
143
- // Clean up old refetch subscription to prevent memory leaks
144
- if (refetchCleanupRef.current) {
145
- refetchCleanupRef.current();
146
- refetchCleanupRef.current = null;
147
- }
148
- // Create new abort signal for this refetch
149
- const abortSignal = { cancelled: false };
150
- refetchAbortRef.current = abortSignal;
151
- try {
152
- dispatch({ type: 'REFETCH_START' });
153
- const result = await fetchCrmProperties(stablePropertyNames, (data) => {
154
- if (!abortSignal.cancelled) {
155
- dispatch({ type: 'REFETCH_SUCCESS', payload: data });
156
- }
157
- }, stableOptions);
158
- if (!abortSignal.cancelled) {
159
- dispatch({ type: 'REFETCH_SUCCESS', payload: result.data });
160
- // Store cleanup for next refetch or unmount
161
- refetchCleanupRef.current = result.cleanup;
162
- }
163
- else {
164
- // If cancelled, clean up immediately
165
- if (result.cleanup) {
166
- result.cleanup();
167
- }
168
- }
169
- }
170
- catch (err) {
171
- if (!abortSignal.cancelled) {
172
- const errorData = err instanceof Error
173
- ? err
174
- : new Error('Failed to refetch CRM properties');
175
- dispatch({ type: 'REFETCH_ERROR', payload: errorData });
176
- }
177
- }
178
- finally {
179
- // Clear the abort ref if this is still the current refetch
180
- if (refetchAbortRef.current === abortSignal) {
181
- refetchAbortRef.current = null;
182
- }
183
- }
184
- }, [stablePropertyNames, stableOptions]);
74
+ }, stableOptions),
75
+ callbacks: {
76
+ onStart: ({ isRefetch }) => dispatch({ type: isRefetch ? 'REFETCH_START' : 'FETCH_START' }),
77
+ onSuccess: (data, { isRefetch }) => dispatch({
78
+ type: isRefetch ? 'REFETCH_SUCCESS' : 'FETCH_SUCCESS',
79
+ payload: data,
80
+ }),
81
+ onError: (error, { isRefetch }) => dispatch({
82
+ type: isRefetch ? 'REFETCH_ERROR' : 'FETCH_ERROR',
83
+ payload: error,
84
+ }),
85
+ },
86
+ deps: [stablePropertyNames, stableOptions],
87
+ defaultErrorMessage: 'Failed to fetch CRM properties',
88
+ });
185
89
  return {
186
90
  ...state,
187
91
  refetch,
@@ -0,0 +1,19 @@
1
+ import type { JsonSerializable } from '../shared/types/shared.ts';
2
+ /**
3
+ * Returns a debounced version of `value` that only updates after the value
4
+ * has stopped changing for `delayMs` milliseconds (default: 300ms).
5
+ *
6
+ * Useful for delaying expensive operations (e.g. search requests) until the
7
+ * user has finished typing.
8
+ *
9
+ * Object and array values are compared by deep equality (via `JSON.stringify`),
10
+ * so values must be JSON-serializable. Functions, Symbols, BigInts, and
11
+ * circular references are not supported.
12
+ *
13
+ * @example
14
+ * ```ts
15
+ * const [searchText, setSearchText] = useState('');
16
+ * const debouncedQuery = useDebounce(searchText, 200);
17
+ * ```
18
+ */
19
+ export declare function useDebounce<T extends JsonSerializable>(value: T, delayMs?: number): T;
@@ -0,0 +1,32 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { useStableValue } from "../internal/hook-utils.js";
3
+ /**
4
+ * Returns a debounced version of `value` that only updates after the value
5
+ * has stopped changing for `delayMs` milliseconds (default: 300ms).
6
+ *
7
+ * Useful for delaying expensive operations (e.g. search requests) until the
8
+ * user has finished typing.
9
+ *
10
+ * Object and array values are compared by deep equality (via `JSON.stringify`),
11
+ * so values must be JSON-serializable. Functions, Symbols, BigInts, and
12
+ * circular references are not supported.
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * const [searchText, setSearchText] = useState('');
17
+ * const debouncedQuery = useDebounce(searchText, 200);
18
+ * ```
19
+ */
20
+ export function useDebounce(value, delayMs = 300) {
21
+ const stableValue = useStableValue(value);
22
+ const [debouncedValue, setDebouncedValue] = useState(stableValue);
23
+ useEffect(() => {
24
+ const timer = setTimeout(() => {
25
+ setDebouncedValue(stableValue);
26
+ }, delayMs);
27
+ return () => {
28
+ clearTimeout(timer);
29
+ };
30
+ }, [stableValue, delayMs]);
31
+ return debouncedValue;
32
+ }
@@ -0,0 +1,35 @@
1
+ export interface FetchSignal {
2
+ cancelled: boolean;
3
+ isRefetch: boolean;
4
+ }
5
+ export interface FetchResult<TData> {
6
+ data: TData;
7
+ cleanup?: () => void;
8
+ }
9
+ export interface FetchContext {
10
+ isRefetch: boolean;
11
+ }
12
+ export interface FetchLifecycleCallbacks<TData> {
13
+ onStart: (context: FetchContext) => void;
14
+ onSuccess: (data: TData, context: FetchContext) => void;
15
+ onError: (error: Error, context: FetchContext) => void;
16
+ }
17
+ export interface UseFetchLifecycleOptions<TData> {
18
+ fetchFn: (signal: FetchSignal) => Promise<FetchResult<TData>>;
19
+ callbacks: FetchLifecycleCallbacks<TData>;
20
+ deps: React.DependencyList;
21
+ defaultErrorMessage?: string;
22
+ }
23
+ export interface UseFetchLifecycleResult {
24
+ refetch: () => Promise<void>;
25
+ }
26
+ /**
27
+ * Shared fetch/refetch/cleanup/abort lifecycle for data hooks.
28
+ *
29
+ * Manages:
30
+ * - Initial fetch on mount and when deps change
31
+ * - Cancellation of in-flight fetches on unmount or deps change
32
+ * - Refetch with abort signal and cleanup tracking
33
+ * - Error normalization
34
+ */
35
+ export declare function useFetchLifecycle<TData>({ fetchFn, callbacks, deps, defaultErrorMessage, }: UseFetchLifecycleOptions<TData>): UseFetchLifecycleResult;
@@ -0,0 +1,103 @@
1
+ import { useCallback, useEffect, useRef } from 'react';
2
+ function normalizeError(err, defaultMessage) {
3
+ return err instanceof Error ? err : new Error(defaultMessage);
4
+ }
5
+ /**
6
+ * Shared fetch/refetch/cleanup/abort lifecycle for data hooks.
7
+ *
8
+ * Manages:
9
+ * - Initial fetch on mount and when deps change
10
+ * - Cancellation of in-flight fetches on unmount or deps change
11
+ * - Refetch with abort signal and cleanup tracking
12
+ * - Error normalization
13
+ */
14
+ export function useFetchLifecycle({ fetchFn, callbacks, deps, defaultErrorMessage = 'An error occurred', }) {
15
+ // Store latest versions in refs to avoid stale closures in refetch
16
+ const fetchFnRef = useRef(fetchFn);
17
+ fetchFnRef.current = fetchFn;
18
+ const callbacksRef = useRef(callbacks);
19
+ callbacksRef.current = callbacks;
20
+ const defaultErrorMessageRef = useRef(defaultErrorMessage);
21
+ defaultErrorMessageRef.current = defaultErrorMessage;
22
+ // Track in-flight refetch to support cancellation
23
+ const refetchAbortRef = useRef(null);
24
+ // Track refetch cleanup function to prevent memory leaks
25
+ const refetchCleanupRef = useRef(null);
26
+ useEffect(() => {
27
+ let cancelled = false;
28
+ let cleanup = null;
29
+ const signal = { cancelled: false, isRefetch: false };
30
+ const fetchData = async () => {
31
+ const context = { isRefetch: false };
32
+ try {
33
+ callbacksRef.current.onStart(context);
34
+ const result = await fetchFnRef.current(signal);
35
+ if (!cancelled) {
36
+ callbacksRef.current.onSuccess(result.data, context);
37
+ cleanup = result.cleanup ?? null;
38
+ }
39
+ }
40
+ catch (err) {
41
+ if (!cancelled) {
42
+ callbacksRef.current.onError(normalizeError(err, defaultErrorMessageRef.current), context);
43
+ }
44
+ }
45
+ };
46
+ fetchData();
47
+ return () => {
48
+ cancelled = true;
49
+ signal.cancelled = true;
50
+ // Call cleanup function to release resources
51
+ if (cleanup) {
52
+ cleanup();
53
+ }
54
+ // Clean up any active refetch subscription
55
+ if (refetchCleanupRef.current) {
56
+ refetchCleanupRef.current();
57
+ refetchCleanupRef.current = null;
58
+ }
59
+ };
60
+ }, deps);
61
+ const refetch = useCallback(async () => {
62
+ // Cancel any in-flight refetch
63
+ if (refetchAbortRef.current) {
64
+ refetchAbortRef.current.cancelled = true;
65
+ }
66
+ // Clean up old refetch subscription to prevent memory leaks
67
+ if (refetchCleanupRef.current) {
68
+ refetchCleanupRef.current();
69
+ refetchCleanupRef.current = null;
70
+ }
71
+ // Create new abort signal for this refetch
72
+ const abortSignal = { cancelled: false, isRefetch: true };
73
+ refetchAbortRef.current = abortSignal;
74
+ const context = { isRefetch: true };
75
+ try {
76
+ callbacksRef.current.onStart(context);
77
+ const result = await fetchFnRef.current(abortSignal);
78
+ if (!abortSignal.cancelled) {
79
+ callbacksRef.current.onSuccess(result.data, context);
80
+ // Store cleanup for next refetch or unmount
81
+ refetchCleanupRef.current = result.cleanup ?? null;
82
+ }
83
+ else {
84
+ // If cancelled, clean up immediately
85
+ if (result.cleanup) {
86
+ result.cleanup();
87
+ }
88
+ }
89
+ }
90
+ catch (err) {
91
+ if (!abortSignal.cancelled) {
92
+ callbacksRef.current.onError(normalizeError(err, defaultErrorMessageRef.current), context);
93
+ }
94
+ }
95
+ finally {
96
+ // Clear the abort ref if this is still the current refetch
97
+ if (refetchAbortRef.current === abortSignal) {
98
+ refetchAbortRef.current = null;
99
+ }
100
+ }
101
+ }, []);
102
+ return { refetch };
103
+ }
package/dist/index.d.ts CHANGED
@@ -3,6 +3,7 @@ export { hubspot } from './hubspot.ts';
3
3
  export { logger } from './logger.ts';
4
4
  export * from './shared/types/index.ts';
5
5
  export { Accordion, Alert, AutoGrid, BarChart, Box, Button, ButtonRow, Card, Checkbox, CurrencyInput, DateInput, DescriptionList, DescriptionListItem, Divider, Dropdown, EmptyState, ErrorState, Flex, Form, Heading, Icon, Illustration, Image, Inline, Input, LineChart, Link, List, LoadingButton, LoadingSpinner, Modal, ModalBody, ModalFooter, MultiSelect, NumberInput, Panel, PanelBody, PanelFooter, PanelSection, ProgressBar, RadioButton, ScoreCircle, SearchInput, Select, Spacer, Stack, Statistics, StatisticsItem, StatisticsTrend, StatusTag, StepIndicator, StepperInput, Tab, Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow, Tabs, Tag, Text, TextArea, Textarea, Tile, TimeInput, Toggle, ToggleGroup, Tooltip, } from './shared/remoteComponents.tsx';
6
+ export { useDebounce } from './hooks/useDebounce.ts';
6
7
  export { useExtensionContext } from './hooks/useExtensionContext.tsx';
7
8
  export { useExtensionActions } from './hooks/useExtensionActions.tsx';
8
9
  export { useExtensionApi } from './hooks/useExtensionApi.tsx';
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ export { hubspot } from "./hubspot.js";
4
4
  export { logger } from "./logger.js";
5
5
  export * from "./shared/types/index.js";
6
6
  export { Accordion, Alert, AutoGrid, BarChart, Box, Button, ButtonRow, Card, Checkbox, CurrencyInput, DateInput, DescriptionList, DescriptionListItem, Divider, Dropdown, EmptyState, ErrorState, Flex, Form, Heading, Icon, Illustration, Image, Inline, Input, LineChart, Link, List, LoadingButton, LoadingSpinner, Modal, ModalBody, ModalFooter, MultiSelect, NumberInput, Panel, PanelBody, PanelFooter, PanelSection, ProgressBar, RadioButton, ScoreCircle, SearchInput, Select, Spacer, Stack, Statistics, StatisticsItem, StatisticsTrend, StatusTag, StepIndicator, StepperInput, Tab, Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow, Tabs, Tag, Text, TextArea, Textarea, Tile, TimeInput, Toggle, ToggleGroup, Tooltip, } from "./shared/remoteComponents.js";
7
+ export { useDebounce } from "./hooks/useDebounce.js";
7
8
  export { useExtensionContext } from "./hooks/useExtensionContext.js";
8
9
  export { useExtensionActions } from "./hooks/useExtensionActions.js";
9
10
  export { useExtensionApi } from "./hooks/useExtensionApi.js";
@@ -1,3 +1,4 @@
1
+ import type { JsonSerializable } from '../shared/types/shared.ts';
1
2
  import type { RendererMocksContext } from '../testing/internal/mocks/index.ts';
2
3
  import type { RendererMocks } from '../testing/types.ts';
3
4
  type AnyFunction = (...args: any[]) => any;
@@ -24,4 +25,9 @@ export declare function useMocksContext(): RendererMocksContext | null;
24
25
  * @returns The children wrapped in the Mocks context provider.
25
26
  */
26
27
  export declare const MocksContextProvider: import("react").Provider<RendererMocksContext | null>;
28
+ /**
29
+ * Stabilizes a value's reference identity across re-renders using deep
30
+ * comparison via JSON.stringify.
31
+ */
32
+ export declare function useStableValue<T extends JsonSerializable>(value: T): T;
27
33
  export {};
@@ -1,4 +1,4 @@
1
- import { createContext, useContext } from 'react';
1
+ import { createContext, useContext, useRef } from 'react';
2
2
  const ReactRenderMocksContext = createContext(null);
3
3
  /**
4
4
  * Creates a mock-aware hook function that can be used to mock the original hook function.
@@ -40,3 +40,18 @@ export function useMocksContext() {
40
40
  * @returns The children wrapped in the Mocks context provider.
41
41
  */
42
42
  export const MocksContextProvider = ReactRenderMocksContext.Provider;
43
+ /**
44
+ * Stabilizes a value's reference identity across re-renders using deep
45
+ * comparison via JSON.stringify.
46
+ */
47
+ export function useStableValue(value) {
48
+ const stableRef = useRef({
49
+ key: JSON.stringify(value),
50
+ value,
51
+ });
52
+ const key = JSON.stringify(value);
53
+ if (key !== stableRef.current.key) {
54
+ stableRef.current = { key, value };
55
+ }
56
+ return stableRef.current.value;
57
+ }
@@ -1,5 +1,5 @@
1
1
  import type { ComponentType, ReactNode } from 'react';
2
- import type { EmptyProps } from '../../../shared/types/shared.ts';
2
+ import type { EmptyProps } from '../../shared/types/shared.ts';
3
3
  /**
4
4
  * The props type for the layout component of [PageRoutes](https://developers.hubspot.com/docs/apps/developer-platform/add-features/ui-extensibility/ui-components/app-page-components/page-routes#pageroutes).
5
5
  */
@@ -72,7 +72,6 @@ export interface PageRoutesProps {
72
72
  *
73
73
  * See [PageRoutes](https://developers.hubspot.com/docs/apps/developer-platform/add-features/ui-extensibility/ui-components/app-page-components/page-routes#pageroutes) for more information.
74
74
  *
75
- * @experimental This component is experimental. Avoid using it in production due to potential breaking changes. Your feedback is valuable for improvements. Stay tuned for updates.
76
75
  */
77
76
  export declare function PageRoutes(__props: PageRoutesProps): null;
78
77
  export declare namespace PageRoutes {