@envive-ai/react-hooks 0.3.60 → 0.3.61

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 (56) hide show
  1. package/dist/atoms/app/index.d.cts +7 -7
  2. package/dist/atoms/app/index.d.ts +7 -7
  3. package/dist/atoms/app/variant.d.ts +6 -6
  4. package/dist/atoms/chat/chatState.d.cts +19 -19
  5. package/dist/atoms/chat/chatState.d.ts +19 -19
  6. package/dist/atoms/chat/form.d.ts +2 -2
  7. package/dist/atoms/chat/index.d.cts +2 -2
  8. package/dist/atoms/chat/index.d.ts +3 -3
  9. package/dist/atoms/chat/lastMessage.d.ts +2 -2
  10. package/dist/atoms/chat/messageQueue.d.cts +6 -6
  11. package/dist/atoms/chat/messageQueue.d.ts +7 -7
  12. package/dist/atoms/chat/performanceMetrics.d.ts +6 -6
  13. package/dist/atoms/chat/renderedWidgetRefs.d.cts +2 -2
  14. package/dist/atoms/chat/renderedWidgetRefs.d.ts +3 -3
  15. package/dist/atoms/chat/replies.d.cts +3 -3
  16. package/dist/atoms/chat/replies.d.ts +3 -3
  17. package/dist/atoms/chat/suggestions.d.ts +2 -2
  18. package/dist/atoms/envive/enviveConfig.d.cts +14 -14
  19. package/dist/atoms/envive/enviveConfig.d.ts +13 -13
  20. package/dist/atoms/globalSearch/globalSearch.d.cts +5 -5
  21. package/dist/atoms/globalSearch/globalSearch.d.ts +5 -5
  22. package/dist/atoms/org/customerService.d.cts +6 -6
  23. package/dist/atoms/org/customerService.d.ts +6 -6
  24. package/dist/atoms/org/graphqlConfig.d.cts +4 -4
  25. package/dist/atoms/org/graphqlConfig.d.ts +4 -4
  26. package/dist/atoms/org/newOrgConfigAtom.d.cts +2 -2
  27. package/dist/atoms/org/newOrgConfigAtom.d.ts +2 -2
  28. package/dist/atoms/org/orgAnalyticsConfig.d.cts +4 -4
  29. package/dist/atoms/org/orgAnalyticsConfig.d.ts +4 -4
  30. package/dist/atoms/search/chatSearch.d.cts +17 -17
  31. package/dist/atoms/search/chatSearch.d.ts +17 -17
  32. package/dist/atoms/search/searchAPI.d.cts +13 -13
  33. package/dist/atoms/search/searchAPI.d.ts +13 -13
  34. package/dist/atoms/widget/chatPreviewLoading.d.cts +2 -2
  35. package/dist/atoms/widget/chatPreviewLoading.d.ts +2 -2
  36. package/dist/contexts/types.d.cts +1 -1
  37. package/dist/contexts/types.d.ts +1 -1
  38. package/dist/contexts/typesV3.d.cts +1 -1
  39. package/dist/contexts/typesV3.d.ts +1 -1
  40. package/dist/hooks/GrabAndScroll/useGrabAndScroll.d.ts +2 -2
  41. package/dist/hooks/Intersection/index.cjs +3 -0
  42. package/dist/hooks/Intersection/index.d.cts +2 -2
  43. package/dist/hooks/Intersection/index.d.ts +2 -2
  44. package/dist/hooks/Intersection/index.js +2 -2
  45. package/dist/hooks/Intersection/useIntersection.cjs +48 -26
  46. package/dist/hooks/Intersection/useIntersection.d.cts +6 -3
  47. package/dist/hooks/Intersection/useIntersection.d.ts +6 -3
  48. package/dist/hooks/Intersection/useIntersection.js +46 -27
  49. package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.cjs +2 -2
  50. package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.js +2 -2
  51. package/dist/hooks/utils.d.cts +1 -1
  52. package/dist/hooks/utils.d.ts +1 -1
  53. package/package.json +1 -1
  54. package/src/hooks/Intersection/__tests__/useIntersection.test.ts +226 -1
  55. package/src/hooks/Intersection/useIntersection.ts +112 -20
  56. package/src/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.ts +3 -1
@@ -1,6 +1,12 @@
1
1
  import { act, renderHook } from '@testing-library/react';
2
2
  import { RefObject } from 'react';
3
- import { useIntersection } from '../useIntersection';
3
+ import Logger from 'src/application/logging/logger';
4
+ import {
5
+ VISIBILITY_FALLBACK_GRACE_MS,
6
+ VISIBILITY_FALLBACK_INTERVAL_MS,
7
+ VISIBILITY_FOREGROUND_GRACE_MS,
8
+ useIntersection,
9
+ } from '../useIntersection';
4
10
 
5
11
  class MockIntersectionObserver {
6
12
  static instances: MockIntersectionObserver[] = [];
@@ -131,4 +137,223 @@ describe('useIntersection', () => {
131
137
 
132
138
  expect(lastObserver().disconnected).toBe(true);
133
139
  });
140
+
141
+ describe('visibility fallback', () => {
142
+ let logErrorSpy: ReturnType<typeof vi.spyOn>;
143
+
144
+ const rect = (partial: Partial<DOMRect>): DOMRect => {
145
+ const base = {
146
+ top: 0,
147
+ left: 0,
148
+ bottom: 0,
149
+ right: 0,
150
+ x: 0,
151
+ y: 0,
152
+ toJSON: () => ({}),
153
+ ...partial,
154
+ };
155
+ // Derive area from the bounds like a real DOMRect, unless explicitly overridden, so
156
+ // the on-screen guard (width/height > 0) sees realistic geometry.
157
+ return {
158
+ ...base,
159
+ width: partial.width ?? base.right - base.left,
160
+ height: partial.height ?? base.bottom - base.top,
161
+ } as DOMRect;
162
+ };
163
+
164
+ const connectedNode = (nodeRect: Partial<DOMRect>) => {
165
+ const node = document.createElement('span');
166
+ document.body.appendChild(node);
167
+ vi.spyOn(node, 'getBoundingClientRect').mockReturnValue(rect(nodeRect));
168
+ return node;
169
+ };
170
+
171
+ beforeEach(() => {
172
+ vi.useFakeTimers();
173
+ logErrorSpy = vi.spyOn(Logger.prototype, 'logError').mockImplementation(() => {});
174
+ });
175
+
176
+ afterEach(() => {
177
+ vi.useRealTimers();
178
+ logErrorSpy.mockRestore();
179
+ document.body.innerHTML = '';
180
+ });
181
+
182
+ it('reports visibility and logs an error when an on-screen element is never reported by the observer', () => {
183
+ const node = connectedNode({ top: 100, bottom: 300, left: 0, right: 200 });
184
+ const ref: RefObject<HTMLElement> = { current: node };
185
+
186
+ const { result } = renderHook(() =>
187
+ useIntersection(ref, '0px', { widget_type: 'ProductCardV3' }),
188
+ );
189
+ expect(result.current).toBe(false);
190
+
191
+ // First check happens after the grace window.
192
+ act(() => {
193
+ vi.advanceTimersByTime(VISIBILITY_FALLBACK_GRACE_MS);
194
+ });
195
+
196
+ expect(result.current).toBe(true);
197
+ expect(logErrorSpy).toHaveBeenCalledTimes(1);
198
+ const [message, , context] = logErrorSpy.mock.calls[0];
199
+ expect(message).toContain('Visibility fallback engaged');
200
+ expect(context).toMatchObject({ widget_type: 'ProductCardV3' });
201
+
202
+ // Fires at most once — once visible, the reconciliation loop stops
203
+ act(() => {
204
+ vi.advanceTimersByTime(VISIBILITY_FALLBACK_INTERVAL_MS * 3);
205
+ });
206
+ expect(logErrorSpy).toHaveBeenCalledTimes(1);
207
+ });
208
+
209
+ it('does not engage for an element that is genuinely off screen', () => {
210
+ const node = connectedNode({ top: 5000, bottom: 5200, left: 0, right: 200 });
211
+ const ref: RefObject<HTMLElement> = { current: node };
212
+
213
+ const { result } = renderHook(() => useIntersection(ref, '0px'));
214
+
215
+ act(() => {
216
+ vi.advanceTimersByTime(VISIBILITY_FALLBACK_GRACE_MS + VISIBILITY_FALLBACK_INTERVAL_MS * 4);
217
+ });
218
+
219
+ expect(result.current).toBe(false);
220
+ expect(logErrorSpy).not.toHaveBeenCalled();
221
+ });
222
+
223
+ it('does not engage when the observer reports visibility first', () => {
224
+ const node = connectedNode({ top: 100, bottom: 300, left: 0, right: 200 });
225
+ const ref: RefObject<HTMLElement> = { current: node };
226
+
227
+ const { result } = renderHook(() => useIntersection(ref, '0px'));
228
+ act(() => {
229
+ lastObserver().fire(true, node);
230
+ });
231
+ expect(result.current).toBe(true);
232
+
233
+ act(() => {
234
+ vi.advanceTimersByTime(VISIBILITY_FALLBACK_GRACE_MS + VISIBILITY_FALLBACK_INTERVAL_MS * 4);
235
+ });
236
+
237
+ expect(logErrorSpy).not.toHaveBeenCalled();
238
+ });
239
+
240
+ it('does not engage for a detached node', () => {
241
+ const node = document.createElement('span');
242
+ vi.spyOn(node, 'getBoundingClientRect').mockReturnValue(
243
+ rect({ top: 100, bottom: 300, left: 0, right: 200 }),
244
+ );
245
+ const ref: RefObject<HTMLElement> = { current: node };
246
+
247
+ const { result } = renderHook(() => useIntersection(ref, '0px'));
248
+
249
+ act(() => {
250
+ vi.advanceTimersByTime(VISIBILITY_FALLBACK_GRACE_MS + VISIBILITY_FALLBACK_INTERVAL_MS * 4);
251
+ });
252
+
253
+ expect(result.current).toBe(false);
254
+ expect(logErrorSpy).not.toHaveBeenCalled();
255
+ });
256
+
257
+ it('recovers on foreground (visibilitychange) without waiting for the poll interval', () => {
258
+ const node = connectedNode({ top: 100, bottom: 300, left: 0, right: 200 });
259
+ const ref: RefObject<HTMLElement> = { current: node };
260
+
261
+ const { result } = renderHook(() =>
262
+ useIntersection(ref, '0px', { widget_type: 'ProductCardV3' }),
263
+ );
264
+ expect(result.current).toBe(false);
265
+
266
+ // Tab returns to foreground; the grace is far shorter than the poll interval.
267
+ act(() => {
268
+ document.dispatchEvent(new Event('visibilitychange'));
269
+ vi.advanceTimersByTime(VISIBILITY_FOREGROUND_GRACE_MS);
270
+ });
271
+
272
+ expect(result.current).toBe(true);
273
+ expect(logErrorSpy).toHaveBeenCalledTimes(1);
274
+ expect(logErrorSpy.mock.calls[0][2]).toMatchObject({ widget_type: 'ProductCardV3' });
275
+ });
276
+
277
+ it('does not engage on foreground when the observer re-fires within the grace window', () => {
278
+ const node = connectedNode({ top: 100, bottom: 300, left: 0, right: 200 });
279
+ const ref: RefObject<HTMLElement> = { current: node };
280
+
281
+ const { result } = renderHook(() => useIntersection(ref, '0px'));
282
+
283
+ // Foreground schedules the grace check...
284
+ act(() => {
285
+ document.dispatchEvent(new Event('visibilitychange'));
286
+ });
287
+ // ...but a healthy observer re-fires first (in its own flush, as it would in the
288
+ // browser within a frame), flipping isVisible and tearing down the grace timer.
289
+ act(() => {
290
+ lastObserver().fire(true, node);
291
+ });
292
+ act(() => {
293
+ vi.advanceTimersByTime(VISIBILITY_FOREGROUND_GRACE_MS * 2);
294
+ });
295
+
296
+ expect(result.current).toBe(true);
297
+ expect(logErrorSpy).not.toHaveBeenCalled();
298
+ });
299
+
300
+ it('does not engage on foreground while the document is still hidden', () => {
301
+ const node = connectedNode({ top: 100, bottom: 300, left: 0, right: 200 });
302
+ const ref: RefObject<HTMLElement> = { current: node };
303
+ const visibilitySpy = vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden');
304
+
305
+ const { result } = renderHook(() => useIntersection(ref, '0px'));
306
+
307
+ act(() => {
308
+ document.dispatchEvent(new Event('visibilitychange'));
309
+ vi.advanceTimersByTime(VISIBILITY_FOREGROUND_GRACE_MS);
310
+ });
311
+
312
+ expect(result.current).toBe(false);
313
+ expect(logErrorSpy).not.toHaveBeenCalled();
314
+ visibilitySpy.mockRestore();
315
+ });
316
+
317
+ it('does not engage for a zero-area element within the viewport (unlaid-out / collapsed)', () => {
318
+ // An inline wrapper that has not laid out its content yet reports a zero-area rect at
319
+ // a viewport position. That is genuinely not-visible, not something the observer missed.
320
+ const node = connectedNode({ top: 100, bottom: 100, left: 50, right: 50 });
321
+ const ref: RefObject<HTMLElement> = { current: node };
322
+
323
+ const { result } = renderHook(() => useIntersection(ref, '0px'));
324
+
325
+ act(() => {
326
+ vi.advanceTimersByTime(VISIBILITY_FALLBACK_GRACE_MS + VISIBILITY_FALLBACK_INTERVAL_MS * 4);
327
+ });
328
+
329
+ expect(result.current).toBe(false);
330
+ expect(logErrorSpy).not.toHaveBeenCalled();
331
+ });
332
+
333
+ it('recovers a stuck on-screen element even while the document is hidden, but stays silent', () => {
334
+ // The environments this fallback exists for — iOS/WebKit in-app webviews and bfcache
335
+ // restores — can report the document as hidden while the shopper is actually looking at
336
+ // it, so recovery must NOT be gated on visibility (that was the CircleCI failure: a
337
+ // headless page reports non-visible and the widget never un-stuck). The error, however,
338
+ // is only a genuine signal when the page is visible — a suppressed observer in a real
339
+ // background tab is expected, not a bug — so it must not fire here and flood telemetry.
340
+ const node = connectedNode({ top: 100, bottom: 300, left: 0, right: 200 });
341
+ const ref: RefObject<HTMLElement> = { current: node };
342
+ const visibilitySpy = vi
343
+ .spyOn(document, 'visibilityState', 'get')
344
+ .mockReturnValue('hidden');
345
+
346
+ const { result } = renderHook(() =>
347
+ useIntersection(ref, '0px', { widget_type: 'ProductCardV3' }),
348
+ );
349
+
350
+ act(() => {
351
+ vi.advanceTimersByTime(VISIBILITY_FALLBACK_GRACE_MS);
352
+ });
353
+
354
+ expect(result.current).toBe(true); // recovered despite being "hidden"
355
+ expect(logErrorSpy).not.toHaveBeenCalled(); // but no false-positive error logged
356
+ visibilitySpy.mockRestore();
357
+ });
358
+ });
134
359
  });
@@ -3,11 +3,49 @@ import Logger from 'src/application/logging/logger';
3
3
 
4
4
  const logger = new Logger('useIntersection');
5
5
 
6
+ // How long to wait before the fallback's FIRST reconcile. Long enough that a healthy
7
+ // IntersectionObserver has delivered its initial callback (so we don't falsely flag a
8
+ // normally-loading widget), short enough to keep the worst-case blank window small.
9
+ // Exported for tests.
10
+ export const VISIBILITY_FALLBACK_GRACE_MS = 1000;
11
+
12
+ // How often the fallback re-checks after the initial grace. A genuinely stuck widget
13
+ // recovers within grace + one interval (~1.3s). Exported for tests.
14
+ export const VISIBILITY_FALLBACK_INTERVAL_MS = 300;
15
+
16
+ // After the tab returns to the foreground, how long to wait before the fast-path reconcile —
17
+ // long enough for a healthy observer to re-fire on its own (so we don't falsely flag it),
18
+ // short enough that recovery is near-instant. Exported for tests.
19
+ export const VISIBILITY_FOREGROUND_GRACE_MS = 200;
20
+
21
+ const isOnScreen = (node: HTMLElement): boolean => {
22
+ const rect = node.getBoundingClientRect();
23
+ // Require real area: an element that has not laid out yet (a 0×0 inline wrapper mid-mount)
24
+ // or is collapsed to zero by an overflow-clipping ancestor is not something the observer
25
+ // wrongly missed — it is genuinely not visible. display:none subtrees report an all-zero
26
+ // rect and are excluded here too.
27
+ if (rect.width <= 0 || rect.height <= 0) {
28
+ return false;
29
+ }
30
+ return (
31
+ rect.top < window.innerHeight &&
32
+ rect.bottom > 0 &&
33
+ rect.left < window.innerWidth &&
34
+ rect.right > 0
35
+ );
36
+ };
37
+
6
38
  // https://rasilbaidar.medium.com/trigger-event-when-element-enters-viewport-the-react-way-168509da2e23
7
- export const useIntersection = (element: RefObject<HTMLElement>, rootMargin: string) => {
39
+ export const useIntersection = (
40
+ element: RefObject<HTMLElement>,
41
+ rootMargin: string,
42
+ logContext?: Record<string, unknown>,
43
+ ) => {
8
44
  const [isVisible, setIsVisible] = useState(false);
9
45
  const observerRef = useRef<IntersectionObserver | null>(null);
10
46
  const observedNodeRef = useRef<HTMLElement | null>(null);
47
+ const logContextRef = useRef(logContext);
48
+ logContextRef.current = logContext;
11
49
 
12
50
  // Runs after every render so it catches when element.current transitions from null to a
13
51
  // mounted DOM node, AND when the node under the ref is replaced. The replacement case is
@@ -18,35 +56,18 @@ export const useIntersection = (element: RefObject<HTMLElement>, rootMargin: str
18
56
  // keeping per-render cost O(1).
19
57
  useEffect(() => {
20
58
  const current = element?.current;
21
- logger.logDebug('effect ran', {
22
- elementCurrent: current,
23
- rootMargin,
24
- alreadyObserving: !!observerRef.current,
25
- observingCurrentNode: observedNodeRef.current === current,
26
- });
27
-
28
59
  if (!current) {
29
- logger.logDebug('element.current is null — observer NOT attached');
30
60
  return;
31
61
  }
32
62
  if (observedNodeRef.current === current) return; // already observing this exact node
33
63
 
34
64
  if (observerRef.current) {
35
- logger.logDebug('observed node was replaced — re-attaching observer', {
36
- previousNode: observedNodeRef.current,
37
- newNode: current,
38
- });
65
+ // Node under the ref was replaced — drop the observer on the detached node.
39
66
  observerRef.current.disconnect();
40
67
  }
41
68
 
42
69
  const observer = new IntersectionObserver(
43
70
  ([entry]) => {
44
- logger.logDebug('IntersectionObserver fired', {
45
- isIntersecting: entry.isIntersecting,
46
- intersectionRatio: entry.intersectionRatio,
47
- boundingClientRect: entry.boundingClientRect,
48
- target: entry.target,
49
- });
50
71
  setIsVisible(entry.isIntersecting);
51
72
  },
52
73
  { rootMargin },
@@ -55,9 +76,80 @@ export const useIntersection = (element: RefObject<HTMLElement>, rootMargin: str
55
76
  observer.observe(current);
56
77
  observerRef.current = observer;
57
78
  observedNodeRef.current = current;
58
- logger.logDebug('observer attached to element', current);
59
79
  });
60
80
 
81
+ // Safety net: visibility detection must not be a single point of failure. While the
82
+ // observer reports not-visible, periodically reconcile against the element's actual
83
+ // position; an element that is on screen while the observer stayed silent means
84
+ // observation is broken for a reason we haven't met yet (wrapped/overridden
85
+ // IntersectionObserver, engine quirk, a failure mode like the stale-node bug). Report it
86
+ // as a console error (so session tools can count affected sessions) and treat the
87
+ // element as visible so it still loads.
88
+ useEffect(() => {
89
+ if (isVisible) {
90
+ return undefined;
91
+ }
92
+
93
+ const reconcile = () => {
94
+ const node = element?.current;
95
+ if (!node || !node.isConnected) {
96
+ return;
97
+ }
98
+ if (isOnScreen(node)) {
99
+ // Recover the widget regardless of document.visibilityState. The environments this
100
+ // fallback exists for — iOS/WebKit in-app webviews and bfcache restores — can report
101
+ // the document as hidden even while the shopper is looking at it, so gating recovery
102
+ // on visibility would defeat the fix in exactly those cases. Only LOG a genuine
103
+ // failure when the page is actually visible, so a legitimately suppressed observer in
104
+ // a hidden/background tab does not flood error telemetry with false positives.
105
+ if (document.visibilityState === 'visible') {
106
+ logger.logError(
107
+ 'Visibility fallback engaged — element is on screen but IntersectionObserver never reported it',
108
+ undefined,
109
+ {
110
+ ...logContextRef.current,
111
+ fallback_interval_ms: VISIBILITY_FALLBACK_INTERVAL_MS,
112
+ },
113
+ );
114
+ }
115
+ setIsVisible(true);
116
+ }
117
+ };
118
+
119
+ // Give the real observer a grace window to deliver its initial callback, then poll on a
120
+ // shorter cadence so a genuinely stuck widget recovers quickly.
121
+ let interval: ReturnType<typeof setInterval> | undefined;
122
+ const grace = setTimeout(() => {
123
+ reconcile();
124
+ interval = setInterval(reconcile, VISIBILITY_FALLBACK_INTERVAL_MS);
125
+ }, VISIBILITY_FALLBACK_GRACE_MS);
126
+
127
+ // Fast path: a widget composited while the tab was hidden can have its
128
+ // IntersectionObserver suppressed, and some engines fail to re-fire it when the tab
129
+ // returns to the foreground for an element already in the viewport. On visibility
130
+ // change, reconcile after a short grace — if a healthy observer re-fires first,
131
+ // isVisible flips and this effect tears the timer down before it runs.
132
+ let foregroundCheck: ReturnType<typeof setTimeout> | undefined;
133
+ const onVisibilityChange = () => {
134
+ if (document.visibilityState !== 'visible') {
135
+ return;
136
+ }
137
+ foregroundCheck = setTimeout(reconcile, VISIBILITY_FOREGROUND_GRACE_MS);
138
+ };
139
+ document.addEventListener('visibilitychange', onVisibilityChange);
140
+
141
+ return () => {
142
+ clearTimeout(grace);
143
+ if (interval !== undefined) {
144
+ clearInterval(interval);
145
+ }
146
+ document.removeEventListener('visibilitychange', onVisibilityChange);
147
+ if (foregroundCheck !== undefined) {
148
+ clearTimeout(foregroundCheck);
149
+ }
150
+ };
151
+ }, [isVisible, element]);
152
+
61
153
  // Disconnect on unmount only — intentionally separate from the setup effect above.
62
154
  useEffect(() => {
63
155
  return () => {
@@ -35,7 +35,9 @@ export const useTrackComponentVisibleEvent = (
35
35
  rootMargin: string = '0px',
36
36
  enabled: boolean = true,
37
37
  ): { isVisible: boolean } => {
38
- const isVisible = useIntersection(element, rootMargin);
38
+ // eventProps (widget_config_id, widget_type, …) double as log context so the hook's
39
+ // visibility-fallback error identifies which widget it rescued.
40
+ const isVisible = useIntersection(element, rootMargin, eventProps);
39
41
  const hasTrackedEvent = useRef(false);
40
42
  const { trackEvent } = useAmplitude();
41
43
  const variantInfo = useAtomValue(pageVariantInfoAtom);