@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.
Files changed (63) hide show
  1. package/dist/application/models/featureGates.cjs +6 -1
  2. package/dist/application/models/featureGates.d.cts +6 -1
  3. package/dist/application/models/featureGates.d.ts +6 -1
  4. package/dist/application/models/featureGates.js +6 -1
  5. package/dist/atoms/app/index.d.cts +7 -7
  6. package/dist/atoms/app/variant.d.cts +6 -6
  7. package/dist/atoms/chat/chatState.d.cts +19 -19
  8. package/dist/atoms/chat/chatState.d.ts +19 -19
  9. package/dist/atoms/chat/form.d.cts +2 -2
  10. package/dist/atoms/chat/form.d.ts +2 -2
  11. package/dist/atoms/chat/index.d.cts +3 -3
  12. package/dist/atoms/chat/index.d.ts +3 -3
  13. package/dist/atoms/chat/lastMessage.d.cts +2 -2
  14. package/dist/atoms/chat/lastMessage.d.ts +2 -2
  15. package/dist/atoms/chat/messageQueue.d.cts +6 -6
  16. package/dist/atoms/chat/performanceMetrics.d.cts +6 -6
  17. package/dist/atoms/chat/performanceMetrics.d.ts +6 -6
  18. package/dist/atoms/chat/renderedWidgetRefs.d.cts +2 -2
  19. package/dist/atoms/chat/renderedWidgetRefs.d.ts +2 -2
  20. package/dist/atoms/chat/replies.d.cts +3 -3
  21. package/dist/atoms/chat/replies.d.ts +2 -2
  22. package/dist/atoms/chat/suggestions.d.cts +2 -2
  23. package/dist/atoms/envive/enviveConfig.d.cts +14 -14
  24. package/dist/atoms/envive/enviveConfig.d.ts +14 -14
  25. package/dist/atoms/globalSearch/globalSearch.d.cts +5 -5
  26. package/dist/atoms/globalSearch/globalSearch.d.ts +5 -5
  27. package/dist/atoms/org/customerService.d.cts +6 -6
  28. package/dist/atoms/org/customerService.d.ts +6 -6
  29. package/dist/atoms/org/graphqlConfig.d.cts +5 -5
  30. package/dist/atoms/org/graphqlConfig.d.ts +4 -4
  31. package/dist/atoms/org/newOrgConfigAtom.d.cts +3 -3
  32. package/dist/atoms/org/newOrgConfigAtom.d.ts +2 -2
  33. package/dist/atoms/org/orgAnalyticsConfig.d.cts +5 -5
  34. package/dist/atoms/org/orgAnalyticsConfig.d.ts +4 -4
  35. package/dist/atoms/search/chatSearch.d.cts +17 -17
  36. package/dist/atoms/search/chatSearch.d.ts +17 -17
  37. package/dist/atoms/search/searchAPI.d.cts +13 -13
  38. package/dist/atoms/search/searchAPI.d.ts +13 -13
  39. package/dist/atoms/search/types.d.cts +1 -1
  40. package/dist/atoms/search/utils.d.cts +1 -1
  41. package/dist/atoms/widget/chatPreviewLoading.d.ts +2 -2
  42. package/dist/contexts/featureFlagServiceContext/featureFlagServiceContext.cjs +2 -1
  43. package/dist/contexts/featureFlagServiceContext/featureFlagServiceContext.d.cts +3 -2
  44. package/dist/contexts/featureFlagServiceContext/featureFlagServiceContext.d.ts +3 -2
  45. package/dist/contexts/featureFlagServiceContext/featureFlagServiceContext.js +2 -2
  46. package/dist/contexts/featureFlagServiceContext/index.cjs +1 -0
  47. package/dist/contexts/featureFlagServiceContext/index.d.cts +2 -2
  48. package/dist/contexts/featureFlagServiceContext/index.d.ts +2 -2
  49. package/dist/contexts/featureFlagServiceContext/index.js +2 -2
  50. package/dist/contexts/pageContext/pageContext.cjs +31 -6
  51. package/dist/contexts/pageContext/pageContext.js +31 -6
  52. package/dist/contexts/systemSettingsContext/systemSettingsContext.d.ts +2 -2
  53. package/dist/contexts/types.d.cts +1 -1
  54. package/dist/contexts/types.d.ts +1 -1
  55. package/dist/contexts/typesV3.d.ts +1 -1
  56. package/dist/hooks/GrabAndScroll/useGrabAndScroll.d.cts +2 -2
  57. package/dist/hooks/GrabAndScroll/useGrabAndScroll.d.ts +2 -2
  58. package/dist/hooks/utils.d.ts +1 -1
  59. package/package.json +1 -1
  60. package/src/application/models/featureGates.ts +8 -0
  61. package/src/contexts/featureFlagServiceContext/featureFlagServiceContext.tsx +6 -1
  62. package/src/contexts/pageContext/__tests__/pageContext.test.tsx +269 -0
  63. package/src/contexts/pageContext/pageContext.tsx +73 -5
@@ -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+IGJvb2xlYW47XG5leHBvcnQgZGVjbGFyZSBlbnVtIFNlYXJjaFJlc3VsdHNTdGF0ZSB7XG4gICAgTG9hZGluZyA9IDAsXG4gICAgUmVzdWx0cyA9IDEsXG4gICAgTm9SZXN1bHRzID0gMlxufVxuZXhwb3J0IGRlY2xhcmUgY29uc3QgZ2V0U2VhcmNoUmVzdWx0c1N0YXRlOiAoaXNMb2FkaW5nU2VhcmNoOiBib29sZWFuLCBzZWFyY2hEYXRhOiBTZWFyY2hSZXN1bHQgfCBudWxsKSA9PiBTZWFyY2hSZXN1bHRzU3RhdGU7XG4iXSwibWFwcGluZ3MiOiI7Ozs7O0FBRUEsSUFBVyw0QkFBNEIsQ0FBQyxVQUFVLGVBQWU7QUFDakUsSUFBVyx1QkFBdUIsQ0FBQyxVQUFVLFVBQVU7QUFDdkQsSUFBVyx1QkFBdUI7Q0FBQztPQUFVO09BQWlCO09BQUE7Q0FBQTtBQUM5RCxJQUFXLHdCQUFpQixDQUFBLEdBQUE7QUFDNUIsSUFBVyxxQkFBZ0IsQ0FBQSxHQUFBO0FBQzNCLElBQVcsd0JBQXdCO0NBQUM7T0FBVTtPQUFvQjtDQUFtQiJ9
22
+ //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidXRpbHMuZC50cyIsIm5hbWVzIjpbXSwic291cmNlcyI6WyIuLi8uLi9zcmMvaG9va3MvdXRpbHMuZC50cyJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgeyBTZWFyY2hSZXN1bHQgfSBmcm9tICdzcmMvYXBwbGljYXRpb24vbW9kZWxzL2FwaS9zZWFyY2gnO1xuaW1wb3J0IHsgVXNlckV2ZW50LCBWYXJpYW50SW5mbyB9IGZyb20gJ3NyYy9hcHBsaWNhdGlvbi9tb2RlbHMnO1xuZXhwb3J0IGRlY2xhcmUgY29uc3QgaXNFbGVtZW50UGFydGlhbGx5VmlzaWJsZTogKGVsPzogSFRNTERpdkVsZW1lbnQgfCBudWxsKSA9PiBib29sZWFuO1xuZXhwb3J0IGRlY2xhcmUgY29uc3QgY3JlYXRlQXBwTG9hZGVkRXZlbnQ6ICgpID0+IFVzZXJFdmVudDtcbmV4cG9ydCBkZWNsYXJlIGNvbnN0IGNyZWF0ZVZpc2l0VXNlckV2ZW50OiAoeyB2YXJpYW50SW5mbywgfToge1xuICAgIHZhcmlhbnRJbmZvOiBWYXJpYW50SW5mbztcbn0pID0+IFVzZXJFdmVudCB8IHVuZGVmaW5lZDtcbmV4cG9ydCBkZWNsYXJlIGNvbnN0IGlzV2l0aGluQnVzaW5lc3NIb3VyczogKHN0YXJ0VGltZTogc3RyaW5nLCBlbmRUaW1lOiBzdHJpbmcsIHRpbWVab25lOiBzdHJpbmcpID0+IGJvb2xlYW47XG5leHBvcnQgZGVjbGFyZSBlbnVtIFNlYXJjaFJlc3VsdHNTdGF0ZSB7XG4gICAgTG9hZGluZyA9IDAsXG4gICAgUmVzdWx0cyA9IDEsXG4gICAgTm9SZXN1bHRzID0gMlxufVxuZXhwb3J0IGRlY2xhcmUgY29uc3QgZ2V0U2VhcmNoUmVzdWx0c1N0YXRlOiAoaXNMb2FkaW5nU2VhcmNoOiBib29sZWFuLCBzZWFyY2hEYXRhOiBTZWFyY2hSZXN1bHQgfCBudWxsKSA9PiBTZWFyY2hSZXN1bHRzU3RhdGU7XG4iXSwibWFwcGluZ3MiOiI7Ozs7O0FBRUEsSUFBVyw0QkFBNEIsQ0FBQyxTQUFTLGVBQWU7QUFDaEUsSUFBVyx1QkFBdUIsQ0FBQyxTQUFTLFVBQVU7QUFDdEQsSUFBVyx1QkFBdUI7Q0FBQztPQUFTO09BQWtCO09BQUE7Q0FBQTtBQUM5RCxJQUFXLHdCQUFpQixDQUFBLEVBQUE7QUFDNUIsSUFBVyxxQkFBZ0IsQ0FBQSxFQUFBO0FBQzNCLElBQVcsd0JBQXdCO0NBQUM7T0FBUztPQUFvQjtDQUFtQiJ9
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@envive-ai/react-hooks",
3
- "version": "0.3.54",
3
+ "version": "0.3.55",
4
4
  "description": "React hooks for connecting to Envive AI services.",
5
5
  "keywords": [
6
6
  "react",
@@ -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
- const FeatureFlagServiceContext = createContext<FeatureFlagContextType | undefined>(undefined);
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
- // If in preview mode or atoms are already set (e.g., by storybook), skip API call
105
- if (previewMode || userEvent || variantInfo) {
106
- setIsLoading(false);
107
- return;
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();