@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.
- package/dist/application/commerce-api.cjs +1 -1
- package/dist/application/commerce-api.js +1 -1
- package/dist/application/models/graphql/index.cjs +0 -4
- package/dist/application/models/graphql/index.d.cts +2 -3
- package/dist/application/models/graphql/index.d.ts +2 -3
- package/dist/application/models/graphql/index.js +1 -3
- package/dist/application/models/graphql/queries/getMerchantColorsQuery.d.cts +2 -3
- package/dist/application/models/graphql/queries/getMerchantColorsQuery.d.ts +2 -3
- package/dist/application/models/graphql/queries/getWidgetConfigQuery.cjs +22 -3
- package/dist/application/models/graphql/queries/getWidgetConfigQuery.js +22 -3
- package/dist/application/models/index.cjs +0 -4
- package/dist/application/models/index.d.cts +2 -3
- package/dist/application/models/index.d.ts +2 -3
- package/dist/application/models/index.js +1 -3
- package/dist/application/utils/index.cjs +1 -1
- package/dist/application/utils/index.js +1 -1
- package/dist/atoms/app/index.d.cts +7 -7
- package/dist/atoms/app/index.d.ts +7 -7
- package/dist/atoms/chat/chatState.d.cts +19 -19
- package/dist/atoms/chat/chatState.d.ts +19 -19
- package/dist/atoms/chat/form.d.cts +2 -2
- package/dist/atoms/chat/form.d.ts +2 -2
- package/dist/atoms/chat/index.d.cts +2 -2
- package/dist/atoms/chat/lastMessage.d.cts +2 -2
- package/dist/atoms/chat/lastMessage.d.ts +2 -2
- package/dist/atoms/chat/messageQueue.d.cts +6 -6
- package/dist/atoms/chat/messageQueue.d.ts +6 -6
- package/dist/atoms/chat/performanceMetrics.d.cts +6 -6
- package/dist/atoms/chat/performanceMetrics.d.ts +6 -6
- package/dist/atoms/chat/renderedWidgetRefs.d.cts +2 -2
- package/dist/atoms/chat/renderedWidgetRefs.d.ts +2 -2
- package/dist/atoms/chat/replies.d.cts +3 -3
- package/dist/atoms/chat/replies.d.ts +3 -3
- package/dist/atoms/chat/suggestions.d.cts +2 -2
- package/dist/atoms/chat/suggestions.d.ts +2 -2
- package/dist/atoms/envive/enviveConfig.d.cts +13 -13
- package/dist/atoms/envive/enviveConfig.d.ts +14 -14
- package/dist/atoms/globalSearch/globalSearch.d.cts +5 -5
- package/dist/atoms/globalSearch/globalSearch.d.ts +5 -5
- package/dist/atoms/org/customerService.d.cts +6 -6
- package/dist/atoms/org/customerService.d.ts +6 -6
- package/dist/atoms/org/graphqlConfig.d.cts +4 -4
- package/dist/atoms/org/graphqlConfig.d.ts +4 -4
- package/dist/atoms/org/newOrgConfigAtom.d.cts +2 -2
- package/dist/atoms/org/newOrgConfigAtom.d.ts +2 -2
- package/dist/atoms/org/orgAnalyticsConfig.d.cts +4 -4
- package/dist/atoms/org/orgAnalyticsConfig.d.ts +4 -4
- package/dist/atoms/search/chatSearch.d.cts +17 -17
- package/dist/atoms/search/chatSearch.d.ts +17 -17
- package/dist/atoms/search/searchAPI.d.cts +13 -13
- package/dist/atoms/search/searchAPI.d.ts +13 -13
- package/dist/atoms/search/types.d.cts +1 -1
- package/dist/atoms/widget/chatPreviewLoading.d.cts +2 -2
- package/dist/atoms/widget/chatPreviewLoading.d.ts +2 -2
- package/dist/contexts/pageContext/pageContext.cjs +1 -1
- package/dist/contexts/pageContext/pageContext.js +1 -1
- package/dist/contexts/types.d.cts +1 -1
- package/dist/contexts/types.d.ts +1 -1
- package/dist/contexts/typesV3.cjs +1 -1
- package/dist/contexts/typesV3.d.cts +8 -1
- package/dist/contexts/typesV3.d.ts +8 -1
- package/dist/contexts/typesV3.js +1 -1
- package/dist/contexts/widgetConfigContext/widgetConfigContext.cjs +22 -31
- package/dist/contexts/widgetConfigContext/widgetConfigContext.js +22 -31
- package/dist/hooks/Intersection/useIntersection.cjs +14 -3
- package/dist/hooks/Intersection/useIntersection.js +14 -3
- package/dist/hooks/PageViewedEvent/usePageViewedEvent.cjs +1 -1
- package/dist/hooks/PageViewedEvent/usePageViewedEvent.js +1 -1
- package/dist/hooks/utils.d.cts +1 -1
- package/dist/hooks/utils.d.ts +1 -1
- package/dist/services/enviveConfigService/enviveConfigService.cjs +3 -24
- package/dist/services/enviveConfigService/enviveConfigService.js +4 -25
- package/dist/services/enviveConfigService/fetchGraphQLConfig.cjs +59 -54
- package/dist/services/enviveConfigService/fetchGraphQLConfig.js +60 -54
- package/dist/services/hardcopyService/hardcopyService.cjs +1 -1
- package/dist/services/hardcopyService/hardcopyService.js +1 -1
- package/dist/types/config-versions.cjs +1 -3
- package/dist/types/config-versions.js +2 -3
- package/package.json +1 -1
- package/src/application/models/graphql/index.ts +0 -1
- package/src/application/models/graphql/queries/getMerchantColorsQuery.ts +4 -15
- package/src/application/models/graphql/queries/getWidgetConfigQuery.ts +35 -14
- package/src/application/models/index.ts +0 -1
- package/src/contexts/pageContext/__tests__/pageContext.test.tsx +1 -2
- package/src/contexts/pageContext/pageContext.tsx +1 -2
- package/src/contexts/typesV3.ts +7 -0
- package/src/contexts/widgetConfigContext/__tests__/widgetConfigContext.test.tsx +52 -0
- package/src/contexts/widgetConfigContext/widgetConfigContext.tsx +39 -36
- package/src/hooks/Intersection/__tests__/useIntersection.test.ts +134 -0
- package/src/hooks/Intersection/useIntersection.ts +20 -4
- package/src/services/enviveConfigService/__tests__/enviveConfigService.test.ts +51 -75
- package/src/services/enviveConfigService/__tests__/fetchGraphQLConfig.test.ts +242 -90
- package/src/services/enviveConfigService/enviveConfigService.ts +15 -41
- package/src/services/enviveConfigService/fetchGraphQLConfig.ts +106 -68
- package/dist/application/models/graphql/queries/getColorsAndFrontendQuery.cjs +0 -18
- package/dist/application/models/graphql/queries/getColorsAndFrontendQuery.js +0 -18
- package/dist/application/models/graphql/queries/getMerchantColorsQuery.cjs +0 -18
- package/dist/application/models/graphql/queries/getMerchantColorsQuery.js +0 -18
- package/dist/application/models/graphql/queries/getMerchantFrontendConfigQuery.cjs +0 -18
- package/dist/application/models/graphql/queries/getMerchantFrontendConfigQuery.d.cts +0 -75
- package/dist/application/models/graphql/queries/getMerchantFrontendConfigQuery.d.ts +0 -75
- package/dist/application/models/graphql/queries/getMerchantFrontendConfigQuery.js +0 -18
- package/src/application/models/graphql/queries/getColorsAndFrontendQuery.ts +0 -13
- package/src/application/models/graphql/queries/getMerchantFrontendConfigQuery.ts +0 -103
|
@@ -1,29 +1,50 @@
|
|
|
1
|
-
// Statsig-experiment-aware unified config endpoint
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
|
17
|
-
//
|
|
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
|
-
|
|
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';
|
package/src/contexts/typesV3.ts
CHANGED
|
@@ -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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
40
|
+
const { widgetConfigId, widgetType } = request;
|
|
41
|
+
const response = frontendConfig?.widgetConfigs?.[widgetConfigId];
|
|
42
|
+
logger.logDebug('returning widget config response', response);
|
|
39
43
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
if (widgetConfigId === 'fake-widget-config-id') {
|
|
45
|
+
return {} as WidgetConfigV3;
|
|
46
|
+
}
|
|
43
47
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
13
|
-
//
|
|
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 (
|
|
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
|
|