@envive-ai/react-hooks 0.3.54 → 0.3.55
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/models/featureGates.cjs +6 -1
- package/dist/application/models/featureGates.d.cts +6 -1
- package/dist/application/models/featureGates.d.ts +6 -1
- package/dist/application/models/featureGates.js +6 -1
- package/dist/atoms/app/index.d.cts +7 -7
- package/dist/atoms/app/variant.d.cts +6 -6
- 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 +3 -3
- package/dist/atoms/chat/index.d.ts +3 -3
- 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/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 +2 -2
- package/dist/atoms/chat/suggestions.d.cts +2 -2
- package/dist/atoms/envive/enviveConfig.d.cts +14 -14
- 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 +5 -5
- package/dist/atoms/org/graphqlConfig.d.ts +4 -4
- package/dist/atoms/org/newOrgConfigAtom.d.cts +3 -3
- package/dist/atoms/org/newOrgConfigAtom.d.ts +2 -2
- package/dist/atoms/org/orgAnalyticsConfig.d.cts +5 -5
- 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/search/utils.d.cts +1 -1
- package/dist/atoms/widget/chatPreviewLoading.d.ts +2 -2
- package/dist/contexts/featureFlagServiceContext/featureFlagServiceContext.cjs +2 -1
- package/dist/contexts/featureFlagServiceContext/featureFlagServiceContext.d.cts +3 -2
- package/dist/contexts/featureFlagServiceContext/featureFlagServiceContext.d.ts +3 -2
- package/dist/contexts/featureFlagServiceContext/featureFlagServiceContext.js +2 -2
- package/dist/contexts/featureFlagServiceContext/index.cjs +1 -0
- package/dist/contexts/featureFlagServiceContext/index.d.cts +2 -2
- package/dist/contexts/featureFlagServiceContext/index.d.ts +2 -2
- package/dist/contexts/featureFlagServiceContext/index.js +2 -2
- package/dist/contexts/pageContext/pageContext.cjs +31 -6
- package/dist/contexts/pageContext/pageContext.js +31 -6
- package/dist/contexts/systemSettingsContext/systemSettingsContext.d.ts +2 -2
- package/dist/contexts/types.d.cts +1 -1
- package/dist/contexts/types.d.ts +1 -1
- package/dist/contexts/typesV3.d.ts +1 -1
- package/dist/hooks/GrabAndScroll/useGrabAndScroll.d.cts +2 -2
- package/dist/hooks/GrabAndScroll/useGrabAndScroll.d.ts +2 -2
- package/dist/hooks/utils.d.ts +1 -1
- package/package.json +1 -1
- package/src/application/models/featureGates.ts +8 -0
- package/src/contexts/featureFlagServiceContext/featureFlagServiceContext.tsx +6 -1
- package/src/contexts/pageContext/__tests__/pageContext.test.tsx +269 -0
- package/src/contexts/pageContext/pageContext.tsx +73 -5
package/dist/hooks/utils.d.ts
CHANGED
|
@@ -19,4 +19,4 @@ declare enum SearchResultsState {
|
|
|
19
19
|
declare const getSearchResultsState: (isLoadingSearch: boolean, searchData: SearchResult | null) => SearchResultsState;
|
|
20
20
|
//#endregion
|
|
21
21
|
export { SearchResultsState, createAppLoadedEvent, createVisitUserEvent, getSearchResultsState, isElementPartiallyVisible, isWithinBusinessHours };
|
|
22
|
-
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidXRpbHMuZC50cyIsIm5hbWVzIjpbXSwic291cmNlcyI6WyIuLi8uLi9zcmMvaG9va3MvdXRpbHMuZC50cyJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgeyBTZWFyY2hSZXN1bHQgfSBmcm9tICdzcmMvYXBwbGljYXRpb24vbW9kZWxzL2FwaS9zZWFyY2gnO1xuaW1wb3J0IHsgVXNlckV2ZW50LCBWYXJpYW50SW5mbyB9IGZyb20gJ3NyYy9hcHBsaWNhdGlvbi9tb2RlbHMnO1xuZXhwb3J0IGRlY2xhcmUgY29uc3QgaXNFbGVtZW50UGFydGlhbGx5VmlzaWJsZTogKGVsPzogSFRNTERpdkVsZW1lbnQgfCBudWxsKSA9PiBib29sZWFuO1xuZXhwb3J0IGRlY2xhcmUgY29uc3QgY3JlYXRlQXBwTG9hZGVkRXZlbnQ6ICgpID0+IFVzZXJFdmVudDtcbmV4cG9ydCBkZWNsYXJlIGNvbnN0IGNyZWF0ZVZpc2l0VXNlckV2ZW50OiAoeyB2YXJpYW50SW5mbywgfToge1xuICAgIHZhcmlhbnRJbmZvOiBWYXJpYW50SW5mbztcbn0pID0+IFVzZXJFdmVudCB8IHVuZGVmaW5lZDtcbmV4cG9ydCBkZWNsYXJlIGNvbnN0IGlzV2l0aGluQnVzaW5lc3NIb3VyczogKHN0YXJ0VGltZTogc3RyaW5nLCBlbmRUaW1lOiBzdHJpbmcsIHRpbWVab25lOiBzdHJpbmcpID0+
|
|
22
|
+
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidXRpbHMuZC50cyIsIm5hbWVzIjpbXSwic291cmNlcyI6WyIuLi8uLi9zcmMvaG9va3MvdXRpbHMuZC50cyJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgeyBTZWFyY2hSZXN1bHQgfSBmcm9tICdzcmMvYXBwbGljYXRpb24vbW9kZWxzL2FwaS9zZWFyY2gnO1xuaW1wb3J0IHsgVXNlckV2ZW50LCBWYXJpYW50SW5mbyB9IGZyb20gJ3NyYy9hcHBsaWNhdGlvbi9tb2RlbHMnO1xuZXhwb3J0IGRlY2xhcmUgY29uc3QgaXNFbGVtZW50UGFydGlhbGx5VmlzaWJsZTogKGVsPzogSFRNTERpdkVsZW1lbnQgfCBudWxsKSA9PiBib29sZWFuO1xuZXhwb3J0IGRlY2xhcmUgY29uc3QgY3JlYXRlQXBwTG9hZGVkRXZlbnQ6ICgpID0+IFVzZXJFdmVudDtcbmV4cG9ydCBkZWNsYXJlIGNvbnN0IGNyZWF0ZVZpc2l0VXNlckV2ZW50OiAoeyB2YXJpYW50SW5mbywgfToge1xuICAgIHZhcmlhbnRJbmZvOiBWYXJpYW50SW5mbztcbn0pID0+IFVzZXJFdmVudCB8IHVuZGVmaW5lZDtcbmV4cG9ydCBkZWNsYXJlIGNvbnN0IGlzV2l0aGluQnVzaW5lc3NIb3VyczogKHN0YXJ0VGltZTogc3RyaW5nLCBlbmRUaW1lOiBzdHJpbmcsIHRpbWVab25lOiBzdHJpbmcpID0+IGJvb2xlYW47XG5leHBvcnQgZGVjbGFyZSBlbnVtIFNlYXJjaFJlc3VsdHNTdGF0ZSB7XG4gICAgTG9hZGluZyA9IDAsXG4gICAgUmVzdWx0cyA9IDEsXG4gICAgTm9SZXN1bHRzID0gMlxufVxuZXhwb3J0IGRlY2xhcmUgY29uc3QgZ2V0U2VhcmNoUmVzdWx0c1N0YXRlOiAoaXNMb2FkaW5nU2VhcmNoOiBib29sZWFuLCBzZWFyY2hEYXRhOiBTZWFyY2hSZXN1bHQgfCBudWxsKSA9PiBTZWFyY2hSZXN1bHRzU3RhdGU7XG4iXSwibWFwcGluZ3MiOiI7Ozs7O0FBRUEsSUFBVyw0QkFBNEIsQ0FBQyxTQUFTLGVBQWU7QUFDaEUsSUFBVyx1QkFBdUIsQ0FBQyxTQUFTLFVBQVU7QUFDdEQsSUFBVyx1QkFBdUI7Q0FBQztPQUFTO09BQWtCO09BQUE7Q0FBQTtBQUM5RCxJQUFXLHdCQUFpQixDQUFBLEVBQUE7QUFDNUIsSUFBVyxxQkFBZ0IsQ0FBQSxFQUFBO0FBQzNCLElBQVcsd0JBQXdCO0NBQUM7T0FBUztPQUFvQjtDQUFtQiJ9
|
package/package.json
CHANGED
|
@@ -27,12 +27,20 @@ export enum FeatureGates {
|
|
|
27
27
|
IsAiSuggestionsVariantEnabled = 'is_ai_suggestions_variant_enabled',
|
|
28
28
|
IsAiSuggestionsVariantAEnabled = 'is_ai_suggestions_variant_a_enabled',
|
|
29
29
|
IsAiSuggestionsVariantBEnabled = 'is_ai_suggestions_variant_b_enabled',
|
|
30
|
+
IsAiSuggestionsVariantCEnabled = 'is_ai_suggestions_variant_c_enabled',
|
|
31
|
+
IsAiSuggestionsVariantDEnabled = 'is_ai_suggestions_variant_d_enabled',
|
|
32
|
+
IsAiSuggestionsVariantEEnabled = 'is_ai_suggestions_variant_e_enabled',
|
|
33
|
+
IsAiSuggestionsVariantKEnabled = 'is_ai_suggestions_variant_k_enabled',
|
|
30
34
|
// Serves the agentic storefront (FullPageSalesAgentV3 welcome overlay)
|
|
31
35
|
// independently of the pdp/plp variant_b experiment. The backend serves the
|
|
32
36
|
// storefront under this gate and falls back to variant_b only until this
|
|
33
37
|
// ships, so an org can run the storefront without opting pdp/plp into variant_b.
|
|
34
38
|
IsAiSuggestionsStorefrontEnabled = 'is_ai_suggestions_storefront_enabled',
|
|
35
39
|
IsVoiceInputEnabled = 'is_voice_input_enabled',
|
|
40
|
+
// Resets the chat/conversation context to the new product when a PDP variant/color change
|
|
41
|
+
// updates the URL client-side (history.pushState/replaceState) without a full reload.
|
|
42
|
+
// Default OFF; enabled per-org via Statsig (initially Naturalizer) to de-risk rollout.
|
|
43
|
+
IsPdpVariantContextResetEnabled = 'is_pdp_variant_context_reset_enabled',
|
|
36
44
|
// Statsig experiments / unified getWidgetConfig endpoint migration. When
|
|
37
45
|
// true, EnviveConfigService calls getWidgetConfig (experiment-aware)
|
|
38
46
|
// instead of the legacy getProductsConfigByVersion / getColorsAndFrontend
|
|
@@ -11,7 +11,12 @@ export interface FeatureFlagContextType {
|
|
|
11
11
|
featureFlagService: FeatureFlagService | undefined;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
// Exported so consumers that must not throw when a provider is absent (e.g. PageProvider
|
|
15
|
+
// rendered bare in unit tests) can read it directly with `useContext` instead of the
|
|
16
|
+
// throwing `useFeatureFlagService` hook.
|
|
17
|
+
export const FeatureFlagServiceContext = createContext<FeatureFlagContextType | undefined>(
|
|
18
|
+
undefined,
|
|
19
|
+
);
|
|
15
20
|
|
|
16
21
|
interface FeatureFlagServiceProviderProps {
|
|
17
22
|
featureFlagService: FeatureFlagService;
|
|
@@ -11,6 +11,11 @@ import {
|
|
|
11
11
|
pageVariantInfoAtom,
|
|
12
12
|
urlResolverAtom,
|
|
13
13
|
} from 'src/atoms/app/variant';
|
|
14
|
+
import { FeatureGates } from 'src/application/models';
|
|
15
|
+
import {
|
|
16
|
+
FeatureFlagService,
|
|
17
|
+
FeatureFlagServiceProvider,
|
|
18
|
+
} from 'src/contexts/featureFlagServiceContext';
|
|
14
19
|
import { mapUrlResolverResponseToVariantInfo } from '../mapping';
|
|
15
20
|
import { PageProvider, usePage } from '../pageContext';
|
|
16
21
|
|
|
@@ -99,6 +104,21 @@ describe('PageProvider', () => {
|
|
|
99
104
|
);
|
|
100
105
|
};
|
|
101
106
|
|
|
107
|
+
// Same as renderWithProviders but with the PDP variant-reset feature gate turned on, so the
|
|
108
|
+
// client-side re-resolution behavior is exercised (it is gated off by default).
|
|
109
|
+
const renderWithVariantResetEnabled = (children: React.ReactNode, previewMode = false) => {
|
|
110
|
+
const featureFlagService = new FeatureFlagService([
|
|
111
|
+
{ name: FeatureGates.IsPdpVariantContextResetEnabled, value: true },
|
|
112
|
+
]);
|
|
113
|
+
return render(
|
|
114
|
+
<Provider>
|
|
115
|
+
<FeatureFlagServiceProvider featureFlagService={featureFlagService}>
|
|
116
|
+
<PageProvider previewMode={previewMode}>{children}</PageProvider>
|
|
117
|
+
</FeatureFlagServiceProvider>
|
|
118
|
+
</Provider>,
|
|
119
|
+
);
|
|
120
|
+
};
|
|
121
|
+
|
|
102
122
|
describe('PageProvider Initialization', () => {
|
|
103
123
|
it('should initialize with pageUrl from window.location.href', async () => {
|
|
104
124
|
window.location.href = 'https://example.com/initial-page';
|
|
@@ -318,6 +338,255 @@ describe('PageProvider', () => {
|
|
|
318
338
|
});
|
|
319
339
|
});
|
|
320
340
|
|
|
341
|
+
describe('Client-side navigation (URL change without reload)', () => {
|
|
342
|
+
// Drives PageProvider via a changeable pageUrl so we can simulate a PDP variant/color
|
|
343
|
+
// swap that pushes a new URL without a full page reload.
|
|
344
|
+
const NavComponent: React.FC = () => {
|
|
345
|
+
const context = usePage();
|
|
346
|
+
return (
|
|
347
|
+
<div>
|
|
348
|
+
<div data-testid="variant-info">
|
|
349
|
+
{context.variantInfo ? JSON.stringify(context.variantInfo) : 'undefined'}
|
|
350
|
+
</div>
|
|
351
|
+
<button
|
|
352
|
+
type="button"
|
|
353
|
+
data-testid="navigate"
|
|
354
|
+
onClick={() => context.setPageUrl('https://example.com/product/variant-b')}
|
|
355
|
+
>
|
|
356
|
+
navigate
|
|
357
|
+
</button>
|
|
358
|
+
</div>
|
|
359
|
+
);
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
it('re-resolves to the new product when the page URL changes after initial resolution', async () => {
|
|
363
|
+
window.location.href = 'https://example.com/product/variant-a';
|
|
364
|
+
|
|
365
|
+
mockResolveUrl.mockImplementation(
|
|
366
|
+
async (url: string) =>
|
|
367
|
+
({
|
|
368
|
+
variant_type: VariantTypeEnum.Pdp,
|
|
369
|
+
specific_details: {
|
|
370
|
+
pdp_attributes: {
|
|
371
|
+
product_id: url.includes('variant-b') ? 'product-b' : 'product-a',
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
ready: true,
|
|
375
|
+
}) as UrlResolverResponse,
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
renderWithVariantResetEnabled(<NavComponent />, false);
|
|
379
|
+
|
|
380
|
+
// Initial resolution to product-a
|
|
381
|
+
await waitFor(() => {
|
|
382
|
+
expect(screen.getByTestId('variant-info').textContent).toContain('product-a');
|
|
383
|
+
});
|
|
384
|
+
expect(mockResolveUrl).toHaveBeenCalledWith('https://example.com/product/variant-a');
|
|
385
|
+
|
|
386
|
+
// Simulate the SPA variant change pushing a new URL
|
|
387
|
+
await act(async () => {
|
|
388
|
+
screen.getByTestId('navigate').click();
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// Even though the variant/userEvent atoms were already set, the new URL must re-resolve
|
|
392
|
+
await waitFor(() => {
|
|
393
|
+
expect(screen.getByTestId('variant-info').textContent).toContain('product-b');
|
|
394
|
+
});
|
|
395
|
+
expect(mockResolveUrl).toHaveBeenCalledWith('https://example.com/product/variant-b');
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
// Drives three sequential navigations via distinct buttons.
|
|
399
|
+
const MultiNavComponent: React.FC = () => {
|
|
400
|
+
const context = usePage();
|
|
401
|
+
return (
|
|
402
|
+
<div>
|
|
403
|
+
<div data-testid="variant-info">
|
|
404
|
+
{context.variantInfo ? JSON.stringify(context.variantInfo) : 'undefined'}
|
|
405
|
+
</div>
|
|
406
|
+
<button
|
|
407
|
+
type="button"
|
|
408
|
+
data-testid="go-b"
|
|
409
|
+
onClick={() => context.setPageUrl('https://example.com/product/variant-b')}
|
|
410
|
+
>
|
|
411
|
+
b
|
|
412
|
+
</button>
|
|
413
|
+
<button
|
|
414
|
+
type="button"
|
|
415
|
+
data-testid="go-c"
|
|
416
|
+
onClick={() => context.setPageUrl('https://example.com/product/variant-c')}
|
|
417
|
+
>
|
|
418
|
+
c
|
|
419
|
+
</button>
|
|
420
|
+
</div>
|
|
421
|
+
);
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
it('recovers on the next navigation after a resolver failure (does not permanently stop)', async () => {
|
|
425
|
+
window.location.href = 'https://example.com/product/variant-a';
|
|
426
|
+
|
|
427
|
+
mockResolveUrl.mockImplementation(async (url: string) => {
|
|
428
|
+
if (url.includes('variant-b')) {
|
|
429
|
+
throw new Error('transient resolver failure');
|
|
430
|
+
}
|
|
431
|
+
return {
|
|
432
|
+
variant_type: VariantTypeEnum.Pdp,
|
|
433
|
+
specific_details: {
|
|
434
|
+
pdp_attributes: {
|
|
435
|
+
product_id: url.includes('variant-c') ? 'product-c' : 'product-a',
|
|
436
|
+
},
|
|
437
|
+
},
|
|
438
|
+
ready: true,
|
|
439
|
+
} as UrlResolverResponse;
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
renderWithVariantResetEnabled(<MultiNavComponent />, false);
|
|
443
|
+
|
|
444
|
+
await waitFor(() => {
|
|
445
|
+
expect(screen.getByTestId('variant-info').textContent).toContain('product-a');
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
// Navigate to B — the resolver throws.
|
|
449
|
+
await act(async () => {
|
|
450
|
+
screen.getByTestId('go-b').click();
|
|
451
|
+
});
|
|
452
|
+
await waitFor(() => {
|
|
453
|
+
expect(mockResolveUrl).toHaveBeenCalledWith('https://example.com/product/variant-b');
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
// Navigate to C — resolution must still happen (the failure must not have permanently
|
|
457
|
+
// disabled re-resolution by poisoning the seed check).
|
|
458
|
+
await act(async () => {
|
|
459
|
+
screen.getByTestId('go-c').click();
|
|
460
|
+
});
|
|
461
|
+
await waitFor(() => {
|
|
462
|
+
expect(mockResolveUrl).toHaveBeenCalledWith('https://example.com/product/variant-c');
|
|
463
|
+
});
|
|
464
|
+
await waitFor(() => {
|
|
465
|
+
expect(screen.getByTestId('variant-info').textContent).toContain('product-c');
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it('drops a stale out-of-order resolution without clobbering the newer product or clearing isLoading', async () => {
|
|
470
|
+
// Two rapid navigations where the OLDER url (a) resolves AFTER the newer one (b) has
|
|
471
|
+
// already started — common on slow/mobile networks. The stale response must be dropped,
|
|
472
|
+
// and it must NOT clear isLoading (the newer, still-pending request owns loading state).
|
|
473
|
+
window.location.href = 'https://example.com/product/variant-a';
|
|
474
|
+
|
|
475
|
+
let resolveA: (r: UrlResolverResponse) => void = () => {};
|
|
476
|
+
let resolveB: (r: UrlResolverResponse) => void = () => {};
|
|
477
|
+
const aPromise = new Promise<UrlResolverResponse>(res => {
|
|
478
|
+
resolveA = res;
|
|
479
|
+
});
|
|
480
|
+
const bPromise = new Promise<UrlResolverResponse>(res => {
|
|
481
|
+
resolveB = res;
|
|
482
|
+
});
|
|
483
|
+
mockResolveUrl.mockImplementation((url: string) =>
|
|
484
|
+
url.includes('variant-b') ? bPromise : aPromise,
|
|
485
|
+
);
|
|
486
|
+
|
|
487
|
+
const OutOfOrderComp: React.FC = () => {
|
|
488
|
+
const context = usePage();
|
|
489
|
+
return (
|
|
490
|
+
<div>
|
|
491
|
+
<div data-testid="variant-info">
|
|
492
|
+
{context.variantInfo ? JSON.stringify(context.variantInfo) : 'undefined'}
|
|
493
|
+
</div>
|
|
494
|
+
<div data-testid="is-loading">{context.isLoading ? 'true' : 'false'}</div>
|
|
495
|
+
<button
|
|
496
|
+
type="button"
|
|
497
|
+
data-testid="go-b"
|
|
498
|
+
onClick={() => context.setPageUrl('https://example.com/product/variant-b')}
|
|
499
|
+
>
|
|
500
|
+
b
|
|
501
|
+
</button>
|
|
502
|
+
</div>
|
|
503
|
+
);
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
renderWithVariantResetEnabled(<OutOfOrderComp />, false);
|
|
507
|
+
|
|
508
|
+
// Initial resolution of /a is in flight (aPromise pending).
|
|
509
|
+
await waitFor(() => {
|
|
510
|
+
expect(mockResolveUrl).toHaveBeenCalledWith('https://example.com/product/variant-a');
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
// Navigate to /b while /a is still pending.
|
|
514
|
+
await act(async () => {
|
|
515
|
+
screen.getByTestId('go-b').click();
|
|
516
|
+
});
|
|
517
|
+
await waitFor(() => {
|
|
518
|
+
expect(mockResolveUrl).toHaveBeenCalledWith('https://example.com/product/variant-b');
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
// The OLD /a resolves last (out of order), after /b became the current URL.
|
|
522
|
+
await act(async () => {
|
|
523
|
+
resolveA({
|
|
524
|
+
variant_type: VariantTypeEnum.Pdp,
|
|
525
|
+
specific_details: { pdp_attributes: { product_id: 'product-a' } },
|
|
526
|
+
ready: true,
|
|
527
|
+
} as UrlResolverResponse);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
// Stale /a is dropped: it must not become the variant, and — critically — isLoading
|
|
531
|
+
// must stay true because /b is still resolving (this is Lucas's fix).
|
|
532
|
+
await waitFor(() => {
|
|
533
|
+
expect(screen.getByTestId('is-loading').textContent).toBe('true');
|
|
534
|
+
});
|
|
535
|
+
expect(screen.getByTestId('variant-info').textContent).not.toContain('product-a');
|
|
536
|
+
|
|
537
|
+
// /b resolves and wins.
|
|
538
|
+
await act(async () => {
|
|
539
|
+
resolveB({
|
|
540
|
+
variant_type: VariantTypeEnum.Pdp,
|
|
541
|
+
specific_details: { pdp_attributes: { product_id: 'product-b' } },
|
|
542
|
+
ready: true,
|
|
543
|
+
} as UrlResolverResponse);
|
|
544
|
+
});
|
|
545
|
+
await waitFor(() => {
|
|
546
|
+
expect(screen.getByTestId('variant-info').textContent).toContain('product-b');
|
|
547
|
+
});
|
|
548
|
+
await waitFor(() => {
|
|
549
|
+
expect(screen.getByTestId('is-loading').textContent).toBe('false');
|
|
550
|
+
});
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it('does NOT re-resolve on a URL change when the feature gate is off (default)', async () => {
|
|
554
|
+
window.location.href = 'https://example.com/product/variant-a';
|
|
555
|
+
|
|
556
|
+
mockResolveUrl.mockImplementation(
|
|
557
|
+
async (url: string) =>
|
|
558
|
+
({
|
|
559
|
+
variant_type: VariantTypeEnum.Pdp,
|
|
560
|
+
specific_details: {
|
|
561
|
+
pdp_attributes: { product_id: url.includes('variant-b') ? 'product-b' : 'product-a' },
|
|
562
|
+
},
|
|
563
|
+
ready: true,
|
|
564
|
+
}) as UrlResolverResponse,
|
|
565
|
+
);
|
|
566
|
+
|
|
567
|
+
// No FeatureFlagServiceProvider -> gate resolves to false -> original behavior.
|
|
568
|
+
renderWithProviders(<NavComponent />, false);
|
|
569
|
+
|
|
570
|
+
await waitFor(() => {
|
|
571
|
+
expect(screen.getByTestId('variant-info').textContent).toContain('product-a');
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
await act(async () => {
|
|
575
|
+
screen.getByTestId('navigate').click();
|
|
576
|
+
});
|
|
577
|
+
// Give any (unwanted) re-resolution time to happen.
|
|
578
|
+
await act(async () => {
|
|
579
|
+
await new Promise<void>(resolve => {
|
|
580
|
+
setTimeout(resolve, 100);
|
|
581
|
+
});
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
// The URL change must not have re-resolved; context stays on the original product.
|
|
585
|
+
expect(mockResolveUrl).not.toHaveBeenCalledWith('https://example.com/product/variant-b');
|
|
586
|
+
expect(screen.getByTestId('variant-info').textContent).toContain('product-a');
|
|
587
|
+
});
|
|
588
|
+
});
|
|
589
|
+
|
|
321
590
|
describe('Variant Info Mapping', () => {
|
|
322
591
|
it('should map PDP variant response correctly', async () => {
|
|
323
592
|
const mockResponse: UrlResolverResponse = {
|
|
@@ -17,6 +17,8 @@ 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';
|
|
21
|
+
import { FeatureFlagServiceContext } from 'src/contexts/featureFlagServiceContext';
|
|
20
22
|
import CommerceApiClient from 'src/application/commerce-api';
|
|
21
23
|
import Logger from 'src/application/logging/logger';
|
|
22
24
|
import { PageVisitCategory, UserEventCategory } from '@spiffy-ai/commerce-api-client';
|
|
@@ -49,6 +51,25 @@ export const PageProvider: React.FC<{
|
|
|
49
51
|
const [userEvent, setUserEvent] = useAtom(pageUserEventAtom);
|
|
50
52
|
const [isSupported, setIsSupported] = useState(false);
|
|
51
53
|
const lastNotReadyUrlRef = useRef<string | null>(null);
|
|
54
|
+
// Tracks the last URL we ran through the resolver (dedupe key). Lets us re-resolve when the
|
|
55
|
+
// page URL changes client-side (e.g. a PDP color/variant swap that pushes a new URL without
|
|
56
|
+
// a full reload) instead of short-circuiting on the stale variant/userEvent atoms. Cleared
|
|
57
|
+
// on failure so the URL can be retried.
|
|
58
|
+
const lastResolvedUrlRef = useRef<string | null>(null);
|
|
59
|
+
// Whether we have ever driven a resolution ourselves. Kept separate from
|
|
60
|
+
// `lastResolvedUrlRef` (which a failure clears) so a failed resolve can retry without the
|
|
61
|
+
// next navigation misreading it as "atoms were seeded externally" and skipping the API call.
|
|
62
|
+
const hasResolvedOnceRef = useRef(false);
|
|
63
|
+
|
|
64
|
+
// Gate: re-resolving the URL on a client-side change (PDP variant reset) is opt-in per org
|
|
65
|
+
// via Statsig. When off, keep the original behavior (resolve once, then short-circuit).
|
|
66
|
+
// Read via useContext (not useFeatureFlagService) so a bare PageProvider — e.g. in unit
|
|
67
|
+
// tests — does not throw; absent provider or gate resolves to false.
|
|
68
|
+
const featureFlagContext = useContext(FeatureFlagServiceContext);
|
|
69
|
+
const isVariantResetEnabled =
|
|
70
|
+
featureFlagContext?.featureFlagService?.isFeatureGateEnabled(
|
|
71
|
+
FeatureGates.IsPdpVariantContextResetEnabled,
|
|
72
|
+
) ?? false;
|
|
52
73
|
|
|
53
74
|
useEffect(() => {
|
|
54
75
|
setPageUrl(window.location.href);
|
|
@@ -101,16 +122,54 @@ export const PageProvider: React.FC<{
|
|
|
101
122
|
return;
|
|
102
123
|
}
|
|
103
124
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
125
|
+
const cleansedUrl = pageUrl.toLowerCase().trim();
|
|
126
|
+
|
|
127
|
+
if (!isVariantResetEnabled) {
|
|
128
|
+
// Gate off: original behavior — resolve once, then short-circuit forever while the
|
|
129
|
+
// variant/userEvent atoms are set (a client-side URL change never re-resolves).
|
|
130
|
+
if (previewMode || userEvent || variantInfo) {
|
|
131
|
+
setIsLoading(false);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
} else {
|
|
135
|
+
// Gate on: re-resolve when the URL changes so a PDP variant swap follows to the new
|
|
136
|
+
// product.
|
|
137
|
+
//
|
|
138
|
+
// Already resolved this exact URL — guards against the effect re-running once the
|
|
139
|
+
// resolver writes the variant/userEvent atoms (they are effect dependencies).
|
|
140
|
+
if (lastResolvedUrlRef.current === cleansedUrl) {
|
|
141
|
+
setIsLoading(false);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
// In preview mode, or when the atoms were seeded externally (e.g. by storybook)
|
|
145
|
+
// before we ourselves resolved anything, honor those atoms and skip the API call.
|
|
146
|
+
// Once we have resolved at least once (`hasResolvedOnceRef`), a changed URL should
|
|
147
|
+
// re-resolve even though the previous variant/userEvent atoms are set.
|
|
148
|
+
if (previewMode || ((userEvent || variantInfo) && !hasResolvedOnceRef.current)) {
|
|
149
|
+
hasResolvedOnceRef.current = true;
|
|
150
|
+
lastResolvedUrlRef.current = cleansedUrl;
|
|
151
|
+
setIsLoading(false);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
hasResolvedOnceRef.current = true;
|
|
155
|
+
lastResolvedUrlRef.current = cleansedUrl;
|
|
108
156
|
}
|
|
109
157
|
|
|
110
|
-
const cleansedUrl = pageUrl.toLowerCase().trim();
|
|
111
158
|
const response =
|
|
112
159
|
urlResolverResponse[cleansedUrl] ??
|
|
113
160
|
((await CommerceApiClient.resolveUrl(cleansedUrl)) as UrlResolverResponse);
|
|
161
|
+
|
|
162
|
+
// A newer navigation started resolving while we awaited (e.g. the shopper clicked
|
|
163
|
+
// through several variant swatches quickly). Drop this now-stale response so an
|
|
164
|
+
// out-of-order resolution can't overwrite the latest variant. Do NOT clear isLoading
|
|
165
|
+
// here: the newer in-flight request owns the loading state, and clearing it would
|
|
166
|
+
// open a window where isLoading=false while variantInfo/isSupported still belong to
|
|
167
|
+
// the previous product (causing widget flashes and a fire-once success ref — see
|
|
168
|
+
// Widgets.tsx — to latch onto the wrong product's state).
|
|
169
|
+
if (isVariantResetEnabled && lastResolvedUrlRef.current !== cleansedUrl) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
114
173
|
setVariantFromUrlResolver(cleansedUrl, response);
|
|
115
174
|
setIsLoading(false);
|
|
116
175
|
setIsSupported(response.ready);
|
|
@@ -125,6 +184,14 @@ export const PageProvider: React.FC<{
|
|
|
125
184
|
});
|
|
126
185
|
}
|
|
127
186
|
} catch (e) {
|
|
187
|
+
// Allow this URL to be retried on a subsequent trigger — but only if a newer
|
|
188
|
+
// navigation hasn't already superseded it, so a stale failure can't discard a newer
|
|
189
|
+
// in-flight resolution. `hasResolvedOnceRef` is intentionally left set, so retrying
|
|
190
|
+
// never falls into the seeded-atoms branch above.
|
|
191
|
+
const failedUrl = pageUrl?.toLowerCase().trim();
|
|
192
|
+
if (failedUrl && lastResolvedUrlRef.current === failedUrl) {
|
|
193
|
+
lastResolvedUrlRef.current = null;
|
|
194
|
+
}
|
|
128
195
|
setIsLoading(false);
|
|
129
196
|
logger.logError('Failed to resolve page URL', e, { pageUrl });
|
|
130
197
|
}
|
|
@@ -139,6 +206,7 @@ export const PageProvider: React.FC<{
|
|
|
139
206
|
variantInfo,
|
|
140
207
|
previewMode,
|
|
141
208
|
onUrlResolverNotReady,
|
|
209
|
+
isVariantResetEnabled,
|
|
142
210
|
]);
|
|
143
211
|
|
|
144
212
|
const { trackEvent } = useAmplitude();
|