@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
@@ -1,6 +1,8 @@
1
- import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
2
- import { createMockAwareHook } from "../../internal/hook-utils.js";
3
- import { calculatePaginationFlags, DEFAULT_PAGE_SIZE, fetchAssociations, } from "../utils/fetchAssociations.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";
4
+ import { calculatePaginationFlags, DEFAULT_PAGE_SIZE, } from "../../utils/pagination.js";
5
+ import { fetchAssociations } from "../utils/fetchAssociations.js";
4
6
  function createInitialState(pageSize) {
5
7
  return {
6
8
  results: [],
@@ -108,39 +110,8 @@ const DEFAULT_OPTIONS = {};
108
110
  function useAssociationsInternal(config, options = DEFAULT_OPTIONS) {
109
111
  const pageSize = config?.pageLength ?? DEFAULT_PAGE_SIZE;
110
112
  const [state, dispatch] = useReducer(associationsReducer, useMemo(() => createInitialState(pageSize), [pageSize]));
111
- /**
112
- * HOOK OPTIMIZATION:
113
- *
114
- * Create stable references for config and options to prevent unnecessary re-renders and API calls.
115
- * Then, external developers can pass inline objects without worrying about memoization
116
- * We handle the deep equality comparison ourselves, and return the same object reference when content is equivalent.
117
- */
118
- const lastConfigRef = useRef();
119
- const lastConfigKeyRef = useRef();
120
- const lastOptionsRef = useRef();
121
- const lastOptionsKeyRef = useRef();
122
- // Track in-flight refetch to support cancellation
123
- const refetchAbortRef = useRef(null);
124
- // Track refetch cleanup function to prevent memory leaks
125
- const refetchCleanupRef = useRef(null);
126
- const stableConfig = useMemo(() => {
127
- const configKey = JSON.stringify(config);
128
- if (configKey === lastConfigKeyRef.current) {
129
- return lastConfigRef.current;
130
- }
131
- lastConfigKeyRef.current = configKey;
132
- lastConfigRef.current = config;
133
- return config;
134
- }, [config]);
135
- const stableOptions = useMemo(() => {
136
- const optionsKey = JSON.stringify(options);
137
- if (optionsKey === lastOptionsKeyRef.current) {
138
- return lastOptionsRef.current;
139
- }
140
- lastOptionsKeyRef.current = optionsKey;
141
- lastOptionsRef.current = options;
142
- return options;
143
- }, [options]);
113
+ const stableConfig = useStableValue(config);
114
+ const stableOptions = useStableValue(options);
144
115
  // Pagination actions
145
116
  const nextPage = useCallback(() => {
146
117
  const paginationFlags = calculatePaginationFlags(state.currentPage, state.hasMore);
@@ -155,122 +126,59 @@ function useAssociationsInternal(config, options = DEFAULT_OPTIONS) {
155
126
  }
156
127
  }, [state.currentPage, state.hasMore]);
157
128
  const reset = useCallback(() => {
158
- dispatch({ type: 'RESET' });
159
- }, []);
160
- // Fetch the associations
161
- useEffect(() => {
162
- let cancelled = false;
163
- let cleanup = null;
164
- const fetchData = async () => {
165
- try {
166
- dispatch({ type: 'FETCH_START' });
167
- // Build request using current offset token
168
- const request = {
169
- toObjectType: stableConfig?.toObjectType,
170
- properties: stableConfig?.properties,
171
- pageLength: pageSize,
172
- offset: state.currentOffset,
173
- };
174
- const result = await fetchAssociations(request, {
175
- propertiesToFormat: stableOptions.propertiesToFormat,
176
- formattingOptions: stableOptions.formattingOptions,
177
- });
178
- if (!cancelled) {
179
- dispatch({
180
- type: 'FETCH_SUCCESS',
181
- payload: {
182
- results: result.data.results,
183
- hasMore: result.data.hasMore,
184
- nextOffset: result.data.nextOffset,
185
- currentOffset: state.currentOffset,
186
- },
187
- });
188
- cleanup = result.cleanup;
189
- }
190
- }
191
- catch (err) {
192
- if (!cancelled) {
193
- const errorData = err instanceof Error
194
- ? err
195
- : new Error('Failed to fetch associations');
196
- dispatch({ type: 'FETCH_ERROR', payload: errorData });
197
- }
198
- }
199
- };
200
- fetchData();
201
- return () => {
202
- cancelled = true;
203
- // Call cleanup function to release resources
204
- if (cleanup) {
205
- cleanup();
206
- }
207
- // Clean up any active refetch subscription
208
- if (refetchCleanupRef.current) {
209
- refetchCleanupRef.current();
210
- refetchCleanupRef.current = null;
211
- }
212
- };
213
- }, [
214
- stableConfig,
215
- stableOptions,
216
- state.currentPage,
217
- state.currentOffset,
218
- pageSize,
219
- ]);
220
- const refetch = useCallback(async () => {
221
- if (refetchAbortRef.current) {
222
- refetchAbortRef.current.cancelled = true;
223
- }
224
- if (refetchCleanupRef.current) {
225
- refetchCleanupRef.current();
226
- 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' });
227
133
  }
228
- const abortSignal = { cancelled: false };
229
- refetchAbortRef.current = abortSignal;
230
- try {
231
- dispatch({ type: 'REFETCH_START' });
232
- // Build request using current offset token to refetch current page
134
+ }, [state.currentPage, state.hasMore]);
135
+ const { refetch } = useFetchLifecycle({
136
+ fetchFn: () => {
233
137
  const request = {
234
138
  toObjectType: stableConfig?.toObjectType,
235
139
  properties: stableConfig?.properties,
236
140
  pageLength: pageSize,
237
141
  offset: state.currentOffset,
238
142
  };
239
- const result = await fetchAssociations(request, {
143
+ return fetchAssociations(request, {
240
144
  propertiesToFormat: stableOptions.propertiesToFormat,
241
145
  formattingOptions: stableOptions.formattingOptions,
242
146
  });
243
- if (!abortSignal.cancelled) {
244
- dispatch({
147
+ },
148
+ callbacks: {
149
+ onStart: ({ isRefetch }) => dispatch({ type: isRefetch ? 'REFETCH_START' : 'FETCH_START' }),
150
+ onSuccess: (data, { isRefetch }) => dispatch(isRefetch
151
+ ? {
245
152
  type: 'REFETCH_SUCCESS',
246
153
  payload: {
247
- results: result.data.results,
248
- hasMore: result.data.hasMore,
249
- nextOffset: result.data.nextOffset,
154
+ results: data.results,
155
+ hasMore: data.hasMore,
156
+ nextOffset: data.nextOffset,
250
157
  },
251
- });
252
- refetchCleanupRef.current = result.cleanup;
253
- }
254
- else {
255
- if (result.cleanup) {
256
- result.cleanup();
257
158
  }
258
- }
259
- }
260
- catch (err) {
261
- if (!abortSignal.cancelled) {
262
- const errorData = err instanceof Error
263
- ? err
264
- : new Error('Failed to refetch associations');
265
- dispatch({ type: 'REFETCH_ERROR', payload: errorData });
266
- }
267
- }
268
- finally {
269
- if (refetchAbortRef.current === abortSignal) {
270
- refetchAbortRef.current = null;
271
- }
272
- }
273
- }, [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
+ });
274
182
  // Calculate pagination flags
275
183
  const paginationFlags = calculatePaginationFlags(state.currentPage, state.hasMore);
276
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,
@@ -1,12 +1,4 @@
1
1
  import type { FetchCrmPropertiesOptions } from './fetchCrmProperties.ts';
2
- export declare const DEFAULT_PAGE_SIZE = 10;
3
- /**
4
- * Calculate pagination flags based on current page and API hasMore flag
5
- */
6
- export declare function calculatePaginationFlags(currentPage: number, hasMore: boolean): {
7
- hasNextPage: boolean;
8
- hasPreviousPage: boolean;
9
- };
10
2
  export type AssociationResult = {
11
3
  toObjectId: number;
12
4
  associationTypes: {
@@ -1,13 +1,3 @@
1
- export const DEFAULT_PAGE_SIZE = 10;
2
- /**
3
- * Calculate pagination flags based on current page and API hasMore flag
4
- */
5
- export function calculatePaginationFlags(currentPage, hasMore) {
6
- return {
7
- hasNextPage: hasMore,
8
- hasPreviousPage: currentPage > 1,
9
- };
10
- }
11
1
  function isAssociationsResponse(data) {
12
2
  if (data === null ||
13
3
  typeof data !== 'object' ||
@@ -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,4 +1,6 @@
1
- import type { RendererSpies } from '../testing/types.ts';
1
+ import type { JsonSerializable } from '../shared/types/shared.ts';
2
+ import type { RendererMocksContext } from '../testing/internal/mocks/index.ts';
3
+ import type { RendererMocks } from '../testing/types.ts';
2
4
  type AnyFunction = (...args: any[]) => any;
3
5
  /**
4
6
  * Creates a mock-aware hook function that can be used to mock the original hook function.
@@ -8,19 +10,24 @@ type AnyFunction = (...args: any[]) => any;
8
10
  * @param originalHookFunction The original hook function to call if no mock is found
9
11
  * @returns The mocked hook function or the original hook function if no mock is found
10
12
  */
11
- export declare const createMockAwareHook: <THookName extends keyof RendererSpies, THookFunction extends AnyFunction>(hookName: THookName, originalHookFunction: THookFunction) => THookFunction;
13
+ export declare const createMockAwareHook: <THookName extends keyof RendererMocks, THookFunction extends AnyFunction>(hookName: THookName, originalHookFunction: THookFunction) => THookFunction;
12
14
  /**
13
15
  * A hook that provides access to the Mocks context.
14
- * Returns the mocks object if inside a MocksContextProvider, otherwise returns null.
16
+ * Returns the mocks context value if inside a MocksContextProvider, otherwise returns null.
15
17
  *
16
- * @returns The mocks object or null if not in a test environment.
18
+ * @returns The mocks context value or null if not in a test environment.
17
19
  */
18
- export declare function useMocksContext(): RendererSpies<"crm.record.tab" | "crm.record.sidebar" | "crm.preview" | "helpdesk.sidebar" | "settings" | "home" | "uie.playground.middle"> | null;
20
+ export declare function useMocksContext(): RendererMocksContext | null;
19
21
  /**
20
22
  * A React component that provides the Mocks context that can be used to provide mocks to the mock-aware hook functions.
21
23
  *
22
24
  * @param children The children to render.
23
25
  * @returns The children wrapped in the Mocks context provider.
24
26
  */
25
- export declare const MocksContextProvider: import("react").Provider<RendererSpies<"crm.record.tab" | "crm.record.sidebar" | "crm.preview" | "helpdesk.sidebar" | "settings" | "home" | "uie.playground.middle"> | null>;
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;
26
33
  export {};