@envive-ai/react-hooks 0.3.58 → 0.3.60

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 (104) hide show
  1. package/dist/application/commerce-api.cjs +1 -1
  2. package/dist/application/commerce-api.js +1 -1
  3. package/dist/application/models/graphql/index.cjs +0 -4
  4. package/dist/application/models/graphql/index.d.cts +2 -3
  5. package/dist/application/models/graphql/index.d.ts +2 -3
  6. package/dist/application/models/graphql/index.js +1 -3
  7. package/dist/application/models/graphql/queries/getMerchantColorsQuery.d.cts +2 -3
  8. package/dist/application/models/graphql/queries/getMerchantColorsQuery.d.ts +2 -3
  9. package/dist/application/models/graphql/queries/getWidgetConfigQuery.cjs +22 -3
  10. package/dist/application/models/graphql/queries/getWidgetConfigQuery.js +22 -3
  11. package/dist/application/models/index.cjs +0 -4
  12. package/dist/application/models/index.d.cts +2 -3
  13. package/dist/application/models/index.d.ts +2 -3
  14. package/dist/application/models/index.js +1 -3
  15. package/dist/application/utils/index.cjs +1 -1
  16. package/dist/application/utils/index.js +1 -1
  17. package/dist/atoms/app/index.d.cts +7 -7
  18. package/dist/atoms/app/index.d.ts +7 -7
  19. package/dist/atoms/chat/chatState.d.cts +19 -19
  20. package/dist/atoms/chat/chatState.d.ts +19 -19
  21. package/dist/atoms/chat/form.d.cts +2 -2
  22. package/dist/atoms/chat/form.d.ts +2 -2
  23. package/dist/atoms/chat/index.d.cts +2 -2
  24. package/dist/atoms/chat/lastMessage.d.cts +2 -2
  25. package/dist/atoms/chat/lastMessage.d.ts +2 -2
  26. package/dist/atoms/chat/messageQueue.d.cts +6 -6
  27. package/dist/atoms/chat/messageQueue.d.ts +6 -6
  28. package/dist/atoms/chat/performanceMetrics.d.cts +6 -6
  29. package/dist/atoms/chat/performanceMetrics.d.ts +6 -6
  30. package/dist/atoms/chat/renderedWidgetRefs.d.cts +2 -2
  31. package/dist/atoms/chat/renderedWidgetRefs.d.ts +2 -2
  32. package/dist/atoms/chat/replies.d.cts +3 -3
  33. package/dist/atoms/chat/replies.d.ts +3 -3
  34. package/dist/atoms/chat/suggestions.d.cts +2 -2
  35. package/dist/atoms/chat/suggestions.d.ts +2 -2
  36. package/dist/atoms/envive/enviveConfig.d.cts +13 -13
  37. package/dist/atoms/envive/enviveConfig.d.ts +14 -14
  38. package/dist/atoms/globalSearch/globalSearch.d.cts +5 -5
  39. package/dist/atoms/globalSearch/globalSearch.d.ts +5 -5
  40. package/dist/atoms/org/customerService.d.cts +6 -6
  41. package/dist/atoms/org/customerService.d.ts +6 -6
  42. package/dist/atoms/org/graphqlConfig.d.cts +4 -4
  43. package/dist/atoms/org/graphqlConfig.d.ts +4 -4
  44. package/dist/atoms/org/newOrgConfigAtom.d.cts +2 -2
  45. package/dist/atoms/org/newOrgConfigAtom.d.ts +2 -2
  46. package/dist/atoms/org/orgAnalyticsConfig.d.cts +4 -4
  47. package/dist/atoms/org/orgAnalyticsConfig.d.ts +4 -4
  48. package/dist/atoms/search/chatSearch.d.cts +17 -17
  49. package/dist/atoms/search/chatSearch.d.ts +17 -17
  50. package/dist/atoms/search/searchAPI.d.cts +13 -13
  51. package/dist/atoms/search/searchAPI.d.ts +13 -13
  52. package/dist/atoms/search/types.d.cts +1 -1
  53. package/dist/atoms/widget/chatPreviewLoading.d.cts +2 -2
  54. package/dist/atoms/widget/chatPreviewLoading.d.ts +2 -2
  55. package/dist/contexts/pageContext/pageContext.cjs +1 -1
  56. package/dist/contexts/pageContext/pageContext.js +1 -1
  57. package/dist/contexts/types.d.cts +1 -1
  58. package/dist/contexts/types.d.ts +1 -1
  59. package/dist/contexts/typesV3.cjs +1 -1
  60. package/dist/contexts/typesV3.d.cts +8 -1
  61. package/dist/contexts/typesV3.d.ts +8 -1
  62. package/dist/contexts/typesV3.js +1 -1
  63. package/dist/contexts/widgetConfigContext/widgetConfigContext.cjs +22 -31
  64. package/dist/contexts/widgetConfigContext/widgetConfigContext.js +22 -31
  65. package/dist/hooks/Intersection/useIntersection.cjs +14 -3
  66. package/dist/hooks/Intersection/useIntersection.js +14 -3
  67. package/dist/hooks/PageViewedEvent/usePageViewedEvent.cjs +1 -1
  68. package/dist/hooks/PageViewedEvent/usePageViewedEvent.js +1 -1
  69. package/dist/hooks/utils.d.cts +1 -1
  70. package/dist/hooks/utils.d.ts +1 -1
  71. package/dist/services/enviveConfigService/enviveConfigService.cjs +3 -24
  72. package/dist/services/enviveConfigService/enviveConfigService.js +4 -25
  73. package/dist/services/enviveConfigService/fetchGraphQLConfig.cjs +59 -54
  74. package/dist/services/enviveConfigService/fetchGraphQLConfig.js +60 -54
  75. package/dist/services/hardcopyService/hardcopyService.cjs +1 -1
  76. package/dist/services/hardcopyService/hardcopyService.js +1 -1
  77. package/dist/types/config-versions.cjs +1 -3
  78. package/dist/types/config-versions.js +2 -3
  79. package/package.json +1 -1
  80. package/src/application/models/graphql/index.ts +0 -1
  81. package/src/application/models/graphql/queries/getMerchantColorsQuery.ts +4 -15
  82. package/src/application/models/graphql/queries/getWidgetConfigQuery.ts +35 -14
  83. package/src/application/models/index.ts +0 -1
  84. package/src/contexts/pageContext/__tests__/pageContext.test.tsx +1 -2
  85. package/src/contexts/pageContext/pageContext.tsx +1 -2
  86. package/src/contexts/typesV3.ts +7 -0
  87. package/src/contexts/widgetConfigContext/__tests__/widgetConfigContext.test.tsx +52 -0
  88. package/src/contexts/widgetConfigContext/widgetConfigContext.tsx +39 -36
  89. package/src/hooks/Intersection/__tests__/useIntersection.test.ts +134 -0
  90. package/src/hooks/Intersection/useIntersection.ts +20 -4
  91. package/src/services/enviveConfigService/__tests__/enviveConfigService.test.ts +51 -75
  92. package/src/services/enviveConfigService/__tests__/fetchGraphQLConfig.test.ts +242 -90
  93. package/src/services/enviveConfigService/enviveConfigService.ts +15 -41
  94. package/src/services/enviveConfigService/fetchGraphQLConfig.ts +106 -68
  95. package/dist/application/models/graphql/queries/getColorsAndFrontendQuery.cjs +0 -18
  96. package/dist/application/models/graphql/queries/getColorsAndFrontendQuery.js +0 -18
  97. package/dist/application/models/graphql/queries/getMerchantColorsQuery.cjs +0 -18
  98. package/dist/application/models/graphql/queries/getMerchantColorsQuery.js +0 -18
  99. package/dist/application/models/graphql/queries/getMerchantFrontendConfigQuery.cjs +0 -18
  100. package/dist/application/models/graphql/queries/getMerchantFrontendConfigQuery.d.cts +0 -75
  101. package/dist/application/models/graphql/queries/getMerchantFrontendConfigQuery.d.ts +0 -75
  102. package/dist/application/models/graphql/queries/getMerchantFrontendConfigQuery.js +0 -18
  103. package/src/application/models/graphql/queries/getColorsAndFrontendQuery.ts +0 -13
  104. package/src/application/models/graphql/queries/getMerchantFrontendConfigQuery.ts +0 -103
@@ -1,29 +1,50 @@
1
- // Statsig-experiment-aware unified config endpoint. Used when the
2
- // `use_unified_config` feature gate is on. Selects productsConfig plus
3
- // the resolution metadata pymono used to pick the version the client
4
- // threads `resolution.base_version` into downstream inference requests
5
- // so they're scoped to the same version that produced the widgets the
6
- // user is rendering. Gates and experiment assignments still come from
7
- // REST /v1/org/config so AmplitudeService can read them synchronously
8
- // before this query resolves. Once the migration completes this query
9
- // will replace getColorsAndFrontendQuery entirely.
1
+ // Statsig-experiment-aware unified config endpoint the single source of
2
+ // session config. Returns productsConfig, the resolution metadata pymono used
3
+ // to pick the version (the client threads `resolution.baseVersion` into
4
+ // downstream inference requests so they're scoped to the version that produced
5
+ // the rendered widgets), AND the org identity + feature gates + experiment
6
+ // assignments that used to come from REST `/v1/org/config`. Pulling all of it
7
+ // here lets the client drop the separate org/config call.
10
8
  //
11
9
  // userId is required by the schema (String!) and feeds Statsig layer
12
- // resolution server-side.
10
+ // resolution server-side. source feeds Statsig user properties.
11
+ //
12
+ // gateNames selects which feature gates to evaluate and return — pass the same
13
+ // list the app checks (the `FeatureGates` enum values); the resolver returns an
14
+ // empty `gates` list when none are requested.
13
15
  //
14
16
  // version is optional; when omitted (no `spiffy_config_version` /
15
17
  // `envive_config_version` URL param) the resolver uses Statsig bucketing.
16
- // When set, it bypasses Statsig and pins to that exact version — same
17
- // debug/rollback affordance the legacy getColorsAndFrontendQuery offered.
18
+ // When set, it bypasses the merchant experiment and pins to that exact version
19
+ // (empty experimentAssignments) the QA / rollback affordance.
18
20
  export const getWidgetConfigQuery = () => `
19
- query($userId: String!, $version: String) {
21
+ query($userId: String!, $version: String, $gateNames: [String!], $source: String) {
20
22
  me {
21
- getWidgetConfig(userId: $userId, version: $version) {
23
+ org {
24
+ id
25
+ shortName
26
+ displayName
27
+ domain
28
+ createdAt
29
+ updatedAt
30
+ }
31
+ getWidgetConfig(userId: $userId, version: $version, gateNames: $gateNames, source: $source) {
22
32
  productsConfig {
23
33
  frontend { values }
24
34
  colors { values }
25
35
  v_three_config { values }
26
36
  }
37
+ gates {
38
+ name
39
+ value
40
+ groupName
41
+ }
42
+ experimentAssignments {
43
+ layerName
44
+ namespace
45
+ allocatedExperimentName
46
+ groupName
47
+ }
27
48
  resolution {
28
49
  baseVersion
29
50
  baseVersionSource
@@ -35,7 +35,6 @@ export * from './api/userEvent';
35
35
  // graphql
36
36
  export * from './graphql';
37
37
  export * from './graphql/queries/getMerchantColorsQuery';
38
- export * from './graphql/queries/getMerchantFrontendConfigQuery';
39
38
  export * from './graphql/queries/getMerchantOrgIdQuery';
40
39
 
41
40
  // type guards
@@ -4,14 +4,13 @@ import { Provider, useAtom } from 'jotai';
4
4
  import React from 'react';
5
5
  import CommerceApiClient from 'src/application/commerce-api';
6
6
  import Logger from 'src/application/logging/logger';
7
- import { VariantTypeEnum } from 'src/application/models';
7
+ import { FeatureGates, VariantTypeEnum } from 'src/application/models';
8
8
  import {
9
9
  UrlResolverResponse,
10
10
  pageUserEventAtom,
11
11
  pageVariantInfoAtom,
12
12
  urlResolverAtom,
13
13
  } from 'src/atoms/app/variant';
14
- import { FeatureGates } from 'src/application/models';
15
14
  import {
16
15
  FeatureFlagService,
17
16
  FeatureFlagServiceProvider,
@@ -17,12 +17,11 @@ import {
17
17
  variantInfoAtom,
18
18
  } from 'src/atoms/app/variant';
19
19
  import { useAtom, useAtomValue } from 'jotai';
20
- import { FeatureGates } from 'src/application/models';
20
+ import { FeatureGates, VariantTypeEnum } from 'src/application/models';
21
21
  import { FeatureFlagServiceContext } from 'src/contexts/featureFlagServiceContext';
22
22
  import CommerceApiClient from 'src/application/commerce-api';
23
23
  import Logger from 'src/application/logging/logger';
24
24
  import { PageVisitCategory, UserEventCategory } from '@spiffy-ai/commerce-api-client';
25
- import { VariantTypeEnum } from 'src/application/models';
26
25
  import { EnviveMetricsEventName, useAmplitude } from 'src/contexts/amplitudeContext';
27
26
  import { mapApiUserEventToUserEvent, mapUrlResolverResponseToVariantInfo } from './mapping';
28
27
  import { PageDetails } from './types';
@@ -407,6 +407,13 @@ interface FullPageSalesAgentWidgetV3Config extends BaseWidgetConfig<WidgetTypeV3
407
407
  chatBackgroundColor?: string;
408
408
  suggestionButtonType?: PromptButtonVariant;
409
409
  productCarouselLayout?: ProductCarouselLayoutV3;
410
+ /**
411
+ * When true (or unset), asking a question opens the conversation in a
412
+ * partial-height sheet that keeps the welcome overlay's product grid
413
+ * visible above it. Set to false to fall back to the full-screen takeover.
414
+ * Default true (undefined and true = partial view enabled).
415
+ */
416
+ partialViewEnabled?: boolean;
410
417
  }
411
418
 
412
419
  type WidgetConfigV3 =
@@ -315,6 +315,58 @@ describe('WidgetConfigProvider', () => {
315
315
  });
316
316
 
317
317
  describe('isLoading State', () => {
318
+ it('resets isLoading after the fake-widget-config-id path (FloatingChat sentinel)', async () => {
319
+ // FloatingChat calls getWidgetConfig with this sentinel id on every page. The early
320
+ // return used to skip resetting the shared loading flag, leaving every not-yet-settled
321
+ // widget on the page stuck in its loading state.
322
+ const TestComponent: React.FC = () => {
323
+ const { getWidgetConfig, isLoading } = useWidgetConfig();
324
+ const [done, setDone] = React.useState(false);
325
+
326
+ const handleGetConfig = async () => {
327
+ await getWidgetConfig({
328
+ widgetType: WidgetTypeV3.FloatingChatV3,
329
+ widgetConfigId: 'fake-widget-config-id',
330
+ });
331
+ setDone(true);
332
+ };
333
+
334
+ return (
335
+ <div>
336
+ <div data-testid="is-loading">{isLoading.toString()}</div>
337
+ <div data-testid="done">{done.toString()}</div>
338
+ <button
339
+ data-testid="get-config-btn"
340
+ type="button"
341
+ onClick={handleGetConfig}
342
+ >
343
+ Get Config
344
+ </button>
345
+ </div>
346
+ );
347
+ };
348
+
349
+ render(
350
+ <TestWrapper
351
+ mockNewOrgConfig={{
352
+ frontendConfig: { widgetConfigs: {} },
353
+ loading: false,
354
+ }}
355
+ >
356
+ <TestComponent />
357
+ </TestWrapper>,
358
+ );
359
+
360
+ await act(async () => {
361
+ screen.getByTestId('get-config-btn').click();
362
+ });
363
+
364
+ await waitFor(() => {
365
+ expect(screen.getByTestId('done')).toHaveTextContent('true');
366
+ expect(screen.getByTestId('is-loading')).toHaveTextContent('false');
367
+ });
368
+ });
369
+
318
370
  it('should be true when frontend config is loading', async () => {
319
371
  render(
320
372
  <TestWrapper
@@ -23,53 +23,56 @@ export const WidgetConfigProvider: React.FC<{ children: ReactNode }> = ({ childr
23
23
 
24
24
  const getWidgetConfig = useCallback(
25
25
  async (request: WidgetConfigRequest): Promise<WidgetConfigV3> => {
26
+ // `isLoadingWidgetConfig` is provider-level state shared by every widget on the page,
27
+ // so it must be reset on EVERY exit path — the finally guarantees that. A leaked
28
+ // `true` (the fake-widget-config-id return used to skip the reset) kept all
29
+ // not-yet-settled widgets stuck rendering their loading state.
26
30
  setIsLoadingWidgetConfig(true);
27
- logger.logDebug('retrieving widget config', {
28
- widgetConfigId: request.widgetConfigId,
29
- widgetType: request.widgetType,
30
- });
31
- if (isFrontendConfigLoading) {
32
- setIsLoadingWidgetConfig(false);
33
- throw new Error('Frontend config is loading');
34
- }
31
+ try {
32
+ logger.logDebug('retrieving widget config', {
33
+ widgetConfigId: request.widgetConfigId,
34
+ widgetType: request.widgetType,
35
+ });
36
+ if (isFrontendConfigLoading) {
37
+ throw new Error('Frontend config is loading');
38
+ }
35
39
 
36
- const { widgetConfigId, widgetType } = request;
37
- const response = frontendConfig?.widgetConfigs?.[widgetConfigId];
38
- logger.logDebug('returning widget config response', response);
40
+ const { widgetConfigId, widgetType } = request;
41
+ const response = frontendConfig?.widgetConfigs?.[widgetConfigId];
42
+ logger.logDebug('returning widget config response', response);
39
43
 
40
- if (widgetConfigId === 'fake-widget-config-id') {
41
- return {} as WidgetConfigV3;
42
- }
44
+ if (widgetConfigId === 'fake-widget-config-id') {
45
+ return {} as WidgetConfigV3;
46
+ }
43
47
 
44
- if (
45
- [
46
- WidgetTypeV3.SocialProofV3,
47
- WidgetTypeV3.ChatPreviewV3,
48
- WidgetTypeV3.ChatPreviewComparisonV3,
49
- WidgetTypeV3.ChatPreviewLoadingV3,
50
- ].includes(widgetType)
51
- ) {
52
- if (response?.type !== WidgetTypeV3.SocialProofV3) {
53
- setIsLoadingWidgetConfig(false);
48
+ if (
49
+ [
50
+ WidgetTypeV3.SocialProofV3,
51
+ WidgetTypeV3.ChatPreviewV3,
52
+ WidgetTypeV3.ChatPreviewComparisonV3,
53
+ WidgetTypeV3.ChatPreviewLoadingV3,
54
+ ].includes(widgetType)
55
+ ) {
56
+ if (response?.type !== WidgetTypeV3.SocialProofV3) {
57
+ throw new Error(
58
+ `Widget type: ${widgetType} does not match widget config type: ${response?.type}`,
59
+ );
60
+ }
61
+ } else if (response?.type !== widgetType) {
54
62
  throw new Error(
55
63
  `Widget type: ${widgetType} does not match widget config type: ${response?.type}`,
56
64
  );
57
65
  }
58
- } else if (response?.type !== widgetType) {
59
- setIsLoadingWidgetConfig(false);
60
- throw new Error(
61
- `Widget type: ${widgetType} does not match widget config type: ${response?.type}`,
62
- );
63
- }
64
66
 
65
- if (!response) {
67
+ if (!response) {
68
+ throw new Error(
69
+ `No widget config response found for widget type: ${request.widgetType} and widget config id: ${request.widgetConfigId}`,
70
+ );
71
+ }
72
+ return response;
73
+ } finally {
66
74
  setIsLoadingWidgetConfig(false);
67
- throw new Error(
68
- `No widget config response found for widget type: ${request.widgetType} and widget config id: ${request.widgetConfigId}`,
69
- );
70
75
  }
71
- setIsLoadingWidgetConfig(false);
72
- return response;
73
76
  },
74
77
  [frontendConfig, isFrontendConfigLoading],
75
78
  );
@@ -0,0 +1,134 @@
1
+ import { act, renderHook } from '@testing-library/react';
2
+ import { RefObject } from 'react';
3
+ import { useIntersection } from '../useIntersection';
4
+
5
+ class MockIntersectionObserver {
6
+ static instances: MockIntersectionObserver[] = [];
7
+
8
+ observedTargets: Element[] = [];
9
+
10
+ disconnected = false;
11
+
12
+ private callback: IntersectionObserverCallback;
13
+
14
+ constructor(callback: IntersectionObserverCallback) {
15
+ this.callback = callback;
16
+ MockIntersectionObserver.instances.push(this);
17
+ }
18
+
19
+ observe(target: Element) {
20
+ this.observedTargets.push(target);
21
+ }
22
+
23
+ disconnect() {
24
+ this.disconnected = true;
25
+ }
26
+
27
+ unobserve() {}
28
+
29
+ fire(isIntersecting: boolean, target: Element) {
30
+ this.callback(
31
+ [
32
+ {
33
+ isIntersecting,
34
+ intersectionRatio: isIntersecting ? 1 : 0,
35
+ boundingClientRect: {} as DOMRectReadOnly,
36
+ target,
37
+ } as IntersectionObserverEntry,
38
+ ],
39
+ this as unknown as IntersectionObserver,
40
+ );
41
+ }
42
+ }
43
+
44
+ const lastObserver = () =>
45
+ MockIntersectionObserver.instances[MockIntersectionObserver.instances.length - 1];
46
+
47
+ describe('useIntersection', () => {
48
+ beforeEach(() => {
49
+ MockIntersectionObserver.instances = [];
50
+ vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
51
+ });
52
+
53
+ afterEach(() => {
54
+ vi.unstubAllGlobals();
55
+ });
56
+
57
+ it('observes the node and reports visibility when the observer fires', () => {
58
+ const node = document.createElement('span');
59
+ const ref: RefObject<HTMLElement> = { current: node };
60
+
61
+ const { result } = renderHook(() => useIntersection(ref, '0px'));
62
+
63
+ expect(MockIntersectionObserver.instances).toHaveLength(1);
64
+ expect(lastObserver().observedTargets).toEqual([node]);
65
+ expect(result.current).toBe(false);
66
+
67
+ act(() => {
68
+ lastObserver().fire(true, node);
69
+ });
70
+ expect(result.current).toBe(true);
71
+ });
72
+
73
+ it('attaches late when the ref starts out null', () => {
74
+ const ref = { current: null as HTMLElement | null };
75
+
76
+ const { rerender } = renderHook(() => useIntersection(ref as RefObject<HTMLElement>, '0px'));
77
+ expect(MockIntersectionObserver.instances).toHaveLength(0);
78
+
79
+ const node = document.createElement('span');
80
+ ref.current = node;
81
+ rerender();
82
+
83
+ expect(MockIntersectionObserver.instances).toHaveLength(1);
84
+ expect(lastObserver().observedTargets).toEqual([node]);
85
+ });
86
+
87
+ it('does not re-attach across rerenders while the node is unchanged', () => {
88
+ const node = document.createElement('span');
89
+ const ref: RefObject<HTMLElement> = { current: node };
90
+
91
+ const { rerender } = renderHook(() => useIntersection(ref, '0px'));
92
+ rerender();
93
+ rerender();
94
+
95
+ expect(MockIntersectionObserver.instances).toHaveLength(1);
96
+ expect(lastObserver().observedTargets).toEqual([node]);
97
+ });
98
+
99
+ it('re-attaches to the new node when the node under the ref is replaced', () => {
100
+ // Mount churn (e.g. a portal re-creating the widget subtree) can swap the DOM node
101
+ // while the hook instance survives. The old implementation kept observing the detached
102
+ // node forever, so the widget read as permanently not-visible and never fetched.
103
+ const nodeA = document.createElement('span');
104
+ const ref: RefObject<HTMLElement> = { current: nodeA };
105
+
106
+ const { result, rerender } = renderHook(() => useIntersection(ref, '0px'));
107
+ const firstObserver = lastObserver();
108
+ expect(firstObserver.observedTargets).toEqual([nodeA]);
109
+
110
+ const nodeB = document.createElement('span');
111
+ (ref as { current: HTMLElement | null }).current = nodeB;
112
+ rerender();
113
+
114
+ expect(firstObserver.disconnected).toBe(true);
115
+ expect(MockIntersectionObserver.instances).toHaveLength(2);
116
+ expect(lastObserver().observedTargets).toEqual([nodeB]);
117
+
118
+ // Visibility of the replacement node is now detected
119
+ act(() => {
120
+ lastObserver().fire(true, nodeB);
121
+ });
122
+ expect(result.current).toBe(true);
123
+ });
124
+
125
+ it('disconnects the observer on unmount', () => {
126
+ const node = document.createElement('span');
127
+ const ref: RefObject<HTMLElement> = { current: node };
128
+
129
+ const { unmount } = renderHook(() => useIntersection(ref, '0px'));
130
+ unmount();
131
+
132
+ expect(lastObserver().disconnected).toBe(true);
133
+ });
134
+ });
@@ -7,23 +7,37 @@ const logger = new Logger('useIntersection');
7
7
  export const useIntersection = (element: RefObject<HTMLElement>, rootMargin: string) => {
8
8
  const [isVisible, setIsVisible] = useState(false);
9
9
  const observerRef = useRef<IntersectionObserver | null>(null);
10
+ const observedNodeRef = useRef<HTMLElement | null>(null);
10
11
 
11
- // Runs after every render so it catches when element.current transitions from null
12
- // to a mounted DOM node (e.g. when a conditionally-rendered element first appears).
13
- // Bails immediately once the observer is attached, keeping per-render cost O(1).
12
+ // Runs after every render so it catches when element.current transitions from null to a
13
+ // mounted DOM node, AND when the node under the ref is replaced. The replacement case is
14
+ // load-bearing: mount churn (e.g. the shadow-root portal re-creating the widget subtree)
15
+ // can swap the DOM node while this hook instance survives, and an observer left on the
16
+ // detached old node never fires again — the widget then reads as permanently not-visible
17
+ // and never starts loading. Bails once the current node is the one being observed,
18
+ // keeping per-render cost O(1).
14
19
  useEffect(() => {
15
20
  const current = element?.current;
16
21
  logger.logDebug('effect ran', {
17
22
  elementCurrent: current,
18
23
  rootMargin,
19
24
  alreadyObserving: !!observerRef.current,
25
+ observingCurrentNode: observedNodeRef.current === current,
20
26
  });
21
27
 
22
28
  if (!current) {
23
29
  logger.logDebug('element.current is null — observer NOT attached');
24
30
  return;
25
31
  }
26
- if (observerRef.current) return; // already observing
32
+ if (observedNodeRef.current === current) return; // already observing this exact node
33
+
34
+ if (observerRef.current) {
35
+ logger.logDebug('observed node was replaced — re-attaching observer', {
36
+ previousNode: observedNodeRef.current,
37
+ newNode: current,
38
+ });
39
+ observerRef.current.disconnect();
40
+ }
27
41
 
28
42
  const observer = new IntersectionObserver(
29
43
  ([entry]) => {
@@ -40,6 +54,7 @@ export const useIntersection = (element: RefObject<HTMLElement>, rootMargin: str
40
54
 
41
55
  observer.observe(current);
42
56
  observerRef.current = observer;
57
+ observedNodeRef.current = current;
43
58
  logger.logDebug('observer attached to element', current);
44
59
  });
45
60
 
@@ -48,6 +63,7 @@ export const useIntersection = (element: RefObject<HTMLElement>, rootMargin: str
48
63
  return () => {
49
64
  observerRef.current?.disconnect();
50
65
  observerRef.current = null;
66
+ observedNodeRef.current = null;
51
67
  };
52
68
  }, []);
53
69