@faststore/core 4.5.0-dev.1 → 4.5.0-dev.3

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.
@@ -1,9 +1,9 @@
1
1
 
2
- > @faststore/core@4.5.0-dev.0 generate /home/runner/work/faststore/faststore/packages/core
2
+ > @faststore/core@4.5.0-dev.2 generate /home/runner/work/faststore/faststore/packages/core
3
3
  > pnpm run gen-types && pnpm run cache-graphql
4
4
 
5
5
 
6
- > @faststore/core@4.5.0-dev.0 gen-types /home/runner/work/faststore/faststore/packages/core
6
+ > @faststore/core@4.5.0-dev.2 gen-types /home/runner/work/faststore/faststore/packages/core
7
7
  > node ../cli/bin/run generate-types .
8
8
 
9
9
  [STARTED] Parse Configuration
@@ -19,7 +19,7 @@
19
19
  [COMPLETED] Generate to /home/runner/work/faststore/faststore/packages/core/@generated/
20
20
  [COMPLETED] Generate outputs
21
21
 
22
- > @faststore/core@4.5.0-dev.0 cache-graphql /home/runner/work/faststore/faststore/packages/core
22
+ > @faststore/core@4.5.0-dev.2 cache-graphql /home/runner/work/faststore/faststore/packages/core
23
23
  > node ../cli/bin/run cache-graphql --config=./discovery.config.default.js --queries=./@generated/persisted-documents.json
24
24
 
25
25
  [Info] - Config file location: /home/runner/work/faststore/faststore/packages/core/discovery.config.default.js
@@ -1,11 +1,11 @@
1
1
 
2
- > @faststore/core@4.5.0-dev.0 test /home/runner/work/faststore/faststore/packages/core
2
+ > @faststore/core@4.5.0-dev.2 test /home/runner/work/faststore/faststore/packages/core
3
3
  > vitest run
4
4
 
5
5
 
6
6
   RUN  v4.0.7 /home/runner/work/faststore/faststore/packages/core
7
7
 
8
- ✓  browser  test/sdk/session/reconcileSessionLocale.browser.test.ts (5 tests) 44ms
8
+ ✓  browser  test/sdk/session/reconcileSessionLocale.browser.test.ts (5 tests) 42ms
9
9
  stderr | test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx
10
10
  Error in optimistic validation: TypeError: Failed to parse URL from /api/graphql?operationName=ValidateCartMutation&operationHash=32c15f8888ca34f223def7972b7f19090808435a
11
11
   at node:internal/deps/undici/undici:16416:13
@@ -32,13 +32,12 @@
32
32
  }
33
33
  }
34
34
 
35
+ ✓  browser  test/components/account/ContractSwitcher.browser.test.tsx (13 tests) 2028ms
36
+ ✓ shows an error state when the list fails to load  610ms
35
37
  stderr | test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx > RecommendationShelf > renders nothing when there is an error
36
38
  Error fetching recommendations undefined boom for campaign vrn:recommendations:acc:rec-top-items-v2:campaign-1
37
39
 
38
- ✓  browser  test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx (8 tests) 254ms
39
- ✓  browser  test/components/account/ContractSwitcher.browser.test.tsx (13 tests) 2294ms
40
- ✓ shows an error state when the list fails to load  682ms
41
- ✓ requires selecting a contract before Confirm is enabled, then switches (REQ-03)  325ms
40
+ ✓  browser  test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx (8 tests) 282ms
42
41
  stderr | test/pages/password-protection.browser.test.tsx > PasswordProtectionLogin > posts the password and redirects to the requested return path
43
42
  Warning: An update to PasswordProtectionLogin inside a test was not wrapped in act(...).
44
43
 
@@ -268,14 +267,12 @@ act(() => {
268
267
  This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act
269
268
  at PasswordProtectionLogin (/home/runner/work/faststore/faststore/packages/core/src/pages/password-protection/index.tsx:19:69)
270
269
 
271
- ✓  browser  test/pages/password-protection.browser.test.tsx (6 tests) 1479ms
272
- ✓ renders the password protection form  659ms
273
- ✓  browser  test/sdk/session/getInitialSession.browser.test.ts (6 tests) 19ms
274
- ✓  browser  test/sdk/account/changeContractToken.browser.test.ts (9 tests) 90ms
275
- ✓  browser  test/components/account/OrganizationDrawer.browser.test.tsx (5 tests) 2251ms
276
- ✓ renders the menu view with contract details and manage footer  1053ms
277
- ✓ opens the contract switcher and returns to the menu on back  529ms
278
- ✓ resets to the menu when the drawer closes  349ms
270
+ ✓  browser  test/pages/password-protection.browser.test.tsx (6 tests) 1279ms
271
+ ✓ renders the password protection form  519ms
272
+ ✓  browser  test/sdk/session/getInitialSession.browser.test.ts (6 tests) 27ms
273
+ ✓  browser  test/components/account/OrganizationDrawer.browser.test.tsx (5 tests) 1699ms
274
+ ✓ renders the menu view with contract details and manage footer  978ms
275
+ ✓ opens the contract switcher and returns to the menu on back  349ms
279
276
  stderr | test/sdk/localization/store-url.browser.test.ts
280
277
  Error in optimistic validation: TypeError: Cannot read properties of undefined (reading 'read')
281
278
  at validateCart (/home/runner/work/faststore/faststore/packages/core/src/sdk/cart/index.ts:119:27)
@@ -283,9 +280,10 @@ This ensures that you're testing the behavior the user would see in the browser.
283
280
  at a (/home/runner/work/faststore/faststore/packages/sdk/dist/es/index.mjs:354:23)
284
281
   at processTicksAndRejections (node:internal/process/task_queues:103:5)
285
282
 
286
- ✓  browser  test/sdk/localization/store-url.browser.test.ts (3 tests) 14ms
287
- ✓  browser  test/sdk/account/useAvailableContracts.browser.test.ts (6 tests) 132ms
288
- ✓  browser  test/sdk/account/useSwitchContract.browser.test.ts (4 tests) 155ms
283
+ ✓  browser  test/sdk/account/changeContractToken.browser.test.ts (9 tests) 92ms
284
+ ✓  browser  test/sdk/localization/store-url.browser.test.ts (3 tests) 17ms
285
+ ✓  browser  test/sdk/account/useAvailableContracts.browser.test.ts (6 tests) 101ms
286
+ ✓  browser  test/sdk/account/useSwitchContract.browser.test.ts (4 tests) 145ms
289
287
  stderr | test/components/account/doLogout.browser.test.ts
290
288
  Error in optimistic validation: TypeError: Cannot read properties of undefined (reading 'read')
291
289
  at validateCart (/home/runner/work/faststore/faststore/packages/core/src/sdk/cart/index.ts:119:27)
@@ -293,7 +291,7 @@ This ensures that you're testing the behavior the user would see in the browser.
293
291
  at a (/home/runner/work/faststore/faststore/packages/sdk/dist/es/index.mjs:354:23)
294
292
   at processTicksAndRejections (node:internal/process/task_queues:103:5)
295
293
 
296
- ✓  browser  test/components/account/doLogout.browser.test.ts (1 test) 61ms
294
+ ✓  browser  test/components/account/doLogout.browser.test.ts (1 test) 87ms
297
295
  stderr | test/sdk/account/clearPersistedSessionState.browser.test.ts
298
296
  Error in optimistic validation: TypeError: Cannot read properties of undefined (reading 'read')
299
297
  at validateCart (/home/runner/work/faststore/faststore/packages/core/src/sdk/cart/index.ts:119:27)
@@ -301,10 +299,10 @@ This ensures that you're testing the behavior the user would see in the browser.
301
299
  at a (/home/runner/work/faststore/faststore/packages/sdk/dist/es/index.mjs:354:23)
302
300
   at processTicksAndRejections (node:internal/process/task_queues:103:5)
303
301
 
304
- ✓  browser  test/components/account/OrganizationDrawerHeader.browser.test.tsx (3 tests) 285ms
305
- ✓  browser  test/components/account/OrganizationDrawerFooter.browser.test.tsx (3 tests) 317ms
306
302
  ✓  browser  test/sdk/account/clearPersistedSessionState.browser.test.ts (1 test) 9ms
307
- ✓  browser  test/utils/isLocalHost.browser.test.ts (3 tests) 4ms
303
+ ✓  browser  test/components/account/OrganizationDrawerFooter.browser.test.tsx (3 tests) 286ms
304
+ ✓  browser  test/components/account/OrganizationDrawerHeader.browser.test.tsx (3 tests) 292ms
305
+ ✓  browser  test/utils/isLocalHost.browser.test.ts (3 tests) 5ms
308
306
  ✓  browser  test/sdk/session/reconcileSessionLocale.disabled.browser.test.ts (1 test) 3ms
309
307
  stderr | test/sdk/account/refreshToken.browser.test.ts
310
308
  Error in optimistic validation: TypeError: Cannot read properties of undefined (reading 'read')
@@ -313,12 +311,12 @@ This ensures that you're testing the behavior the user would see in the browser.
313
311
  at a (/home/runner/work/faststore/faststore/packages/sdk/dist/es/index.mjs:354:23)
314
312
   at processTicksAndRejections (node:internal/process/task_queues:103:5)
315
313
 
316
- ✓  node  test/utils/localization/bindingPaths.test.ts (71 tests) 31ms
317
- ✓  browser  test/sdk/account/refreshToken.browser.test.ts (1 test) 4ms
318
- ✓  node  test/utils/match-url.test.ts (10 tests) 8ms
319
- ✓  node  test/server/password-protection-service.test.ts (22 tests) 38ms
320
- ✓  node  test/sdk/localization/bindingSelector.test.ts (18 tests) 10ms
321
- ✓  node  test/pages/api/unlock.test.ts (15 tests) 175ms
314
+ ✓  node  test/utils/localization/bindingPaths.test.ts (71 tests) 25ms
315
+ ✓  browser  test/sdk/account/refreshToken.browser.test.ts (1 test) 6ms
316
+ ✓  node  test/server/password-protection-service.test.ts (22 tests) 27ms
317
+ ✓  node  test/utils/match-url.test.ts (10 tests) 7ms
318
+ ✓  node  test/sdk/localization/bindingSelector.test.ts (18 tests) 7ms
319
+ ✓  node  test/pages/api/unlock.test.ts (15 tests) 195ms
322
320
  stderr | test/components/search/SearchInputComponent.test.tsx
323
321
  Warning: forwardRef render functions accept exactly two parameters: props and ref. Did you forget to use the ref parameter?
324
322
 
@@ -382,23 +380,23 @@ Warning: React does not recognize the `providerProps` prop on a DOM element. If
382
380
  at Suspense
383
381
  at Wrapper (/home/runner/work/faststore/faststore/packages/core/test/components/search/SearchInputComponent.test.tsx:106:20)
384
382
 
385
- ✓  node  test/components/search/SearchInputComponent.test.tsx (6 tests) 91ms
386
- ✓  node  test/sdk/localization/useBindingSelector.test.tsx (12 tests) 12ms
387
- ✓  node  test/utils/cookieCacheBusting.test.ts (10 tests) 17ms
388
- ✓  node  test/sdk/search/useSearchHistory.test.ts (13 tests) 58ms
389
- ✓  node  test/utils/clearCookies.test.ts (20 tests) 21ms
390
- ✓  node  test/components/quotes/MyAccountListQuotesTable.test.tsx (12 tests) 316ms
383
+ ✓  node  test/components/search/SearchInputComponent.test.tsx (6 tests) 67ms
384
+ ✓  node  test/utils/cookieCacheBusting.test.ts (10 tests) 19ms
385
+ ✓  node  test/sdk/localization/useBindingSelector.test.tsx (12 tests) 10ms
386
+ ✓  node  test/sdk/search/useSearchHistory.test.ts (13 tests) 72ms
387
+ ✓  node  test/utils/clearCookies.test.ts (20 tests) 28ms
388
+ ✓  node  test/components/quotes/MyAccountListQuotesTable.test.tsx (12 tests) 329ms
391
389
  stderr | test/sdk/account/useSetPassword.test.ts > useSetPassword > maps wrongcredentials error responses to the existing message
392
390
  Set password request failed: Bad Request
393
391
 
394
- ✓  node  test/sdk/account/useSetPassword.test.ts (6 tests) 30ms
392
+ ✓  node  test/sdk/account/useSetPassword.test.ts (6 tests) 39ms
395
393
  ✓  node  test/components/quotes/quoteFilters.test.ts (23 tests) 24ms
396
- ✓  node  test/components/quotes/MyAccountListQuotes.test.tsx (9 tests) 835ms
394
+ ✓  node  test/components/quotes/MyAccountListQuotes.test.tsx (9 tests) 823ms
397
395
  ✓ navigates with the search term on blur  359ms
398
- ✓ navigates without a label param when the search is cleared  363ms
399
- ✓  node  test/utils/multipleTemplates.test.ts (8 tests) 7ms
400
- ✓  node  test/utils/localization/localizeRedirectDestination.test.ts (11 tests) 6ms
401
- ✓  node  test/sdk/orderEntry/useOrderEntryOperation.test.ts (8 tests) 30ms
396
+ ✓ navigates without a label param when the search is cleared  359ms
397
+ ✓  node  test/utils/multipleTemplates.test.ts (8 tests) 6ms
398
+ ✓  node  test/utils/localization/localizeRedirectDestination.test.ts (11 tests) 8ms
399
+ ✓  node  test/sdk/orderEntry/useOrderEntryOperation.test.ts (8 tests) 38ms
402
400
  stderr | test/pages/api/graphql.test.ts > /api/graphql error status propagation > propagates the upstream 400 from a wrapped BadRequestError instead of 500
403
401
  Graphql execution returned with error: [
404
402
  {
@@ -445,46 +443,46 @@ Warning: React does not recognize the `providerProps` prop on a DOM element. If
445
443
  { message: 'bad', status: 400, type: 'BadRequestError' }
446
444
  ]
447
445
 
448
- ✓  node  test/pages/api/graphql.test.ts (10 tests) 25ms
449
- ✓  node  test/sdk/orderEntry/useOrderEntryUpload.test.ts (8 tests) 36ms
450
- ✓  node  test/sdk/orderEntry/useOrderEntry.test.ts (8 tests) 50ms
451
- ✓  node  test/components/quotes/MyAccountQuotesFilterSlider.test.tsx (5 tests) 173ms
452
- ✓  node  test/proxy.localization.test.ts (5 tests) 76ms
453
- ✓  node  test/server/index.test.ts (7 tests) 1060ms
454
- ✓ should handle options and execute  540ms
455
- ✓  node  test/proxy.test.ts (4 tests) 20ms
456
- ✓  node  test/server/webops-api.test.ts (9 tests) 18ms
457
- ✓  node  test/components/quotes/MyAccountQuotesSelectedTags.test.tsx (8 tests) 98ms
458
- ✓  node  test/sdk/orderEntry/useOrderFormItems.test.ts (7 tests) 32ms
446
+ ✓  node  test/pages/api/graphql.test.ts (10 tests) 17ms
447
+ ✓  node  test/sdk/orderEntry/useOrderEntryUpload.test.ts (8 tests) 56ms
448
+ ✓  node  test/sdk/analytics/useStartRecommendationSession.test.ts (6 tests) 50ms
449
+ ✓  node  test/components/quotes/MyAccountQuotesFilterSlider.test.tsx (5 tests) 138ms
450
+ ✓  node  test/proxy.localization.test.ts (5 tests) 51ms
451
+ ✓  node  test/server/index.test.ts (7 tests) 699ms
452
+ ✓  node  test/sdk/orderEntry/useOrderEntry.test.ts (8 tests) 30ms
453
+ ✓  node  test/proxy.test.ts (4 tests) 32ms
454
+ ✓  node  test/sdk/orderEntry/useOrderFormItems.test.ts (7 tests) 38ms
455
+ ✓  node  test/server/webops-api.test.ts (9 tests) 21ms
456
+ ✓  node  test/components/quotes/MyAccountQuotesSelectedTags.test.tsx (8 tests) 105ms
457
+ ✓  node  test/sdk/account/refreshToken.test.ts (5 tests) 9ms
459
458
  ✓  node  test/server/cms/global.test.ts (3 tests) 5ms
460
- ✓  node  test/sdk/account/refreshToken.test.ts (5 tests) 8ms
461
- ✓  node  test/sdk/account/getMyAccountRoutes.test.ts (8 tests) 14ms
462
- ✓  node  test/server/content/service.test.ts (5 tests) 7ms
459
+ ✓  node  test/sdk/account/getMyAccountRoutes.test.ts (8 tests) 5ms
460
+ ✓  node  test/server/content/service.test.ts (5 tests) 5ms
463
461
  ✓  node  test/sdk/localization/store-url.test.ts (1 test) 4ms
464
- ✓  node  test/sdk/auth/useAuth.test.ts (5 tests) 21ms
465
- ✓  node  test/utils/getRequestHostname.test.ts (12 tests) 5ms
466
- ✓  node  test/components/quotes/MyAccountQuotesStatusSelector.test.tsx (6 tests) 143ms
467
- ✓  node  test/pages/api/preview.test.ts (2 tests) 10ms
462
+ ✓  node  test/sdk/analytics/hasEnabledRecommendationShelf.test.ts (6 tests) 5ms
463
+ ✓  node  test/sdk/auth/useAuth.test.ts (5 tests) 20ms
464
+ ✓  node  test/utils/getRequestHostname.test.ts (12 tests) 7ms
465
+ ✓  node  test/components/quotes/MyAccountQuotesStatusSelector.test.tsx (6 tests) 142ms
466
+ ✓  node  test/pages/api/preview.test.ts (2 tests) 8ms
467
+ ✓  node  test/utils/validateSessionRefreshToken.test.ts (6 tests) 8ms
468
468
  ✓  node  test/components/search/SearchInput.test.ts (6 tests) 7ms
469
- ✓  node  test/utils/validateSessionRefreshToken.test.ts (6 tests) 5ms
470
- ✓  node  test/sdk/analytics/useStartRecommendationSession.test.ts (3 tests) 31ms
471
- ✓  node  test/components/RecommendationShelf/vrn.test.ts (5 tests) 6ms
472
- ✓  node  test/components/RecommendationShelf/useRecommendationUserId.test.ts (4 tests) 193ms
473
- ✓  node  test/components/account/Layout.test.tsx (4 tests) 83ms
469
+ ✓  node  test/components/RecommendationShelf/vrn.test.ts (5 tests) 5ms
474
470
  ✓  node  test/server/cms/index.test.ts (2 tests) 6ms
475
- ✓  node  test/utils/retry.test.ts (5 tests) 14ms
476
- ✓  node  test/utils/unlockResponse.test.ts (11 tests) 6ms
471
+ ✓  node  test/utils/retry.test.ts (5 tests) 13ms
472
+ ✓  node  test/components/account/Layout.test.tsx (4 tests) 37ms
473
+ ✓  node  test/components/RecommendationShelf/useRecommendationUserId.test.ts (4 tests) 190ms
474
+ ✓  node  test/utils/unlockResponse.test.ts (11 tests) 5ms
477
475
  ✓  node  test/components/RecommendationShelf/useRecommendations.test.ts (2 tests) 5ms
478
476
  ✓  node  test/utils/isLocalHost.test.ts (7 tests) 5ms
479
477
  ✓  node  test/utils/userOrderStatus.test.ts (3 tests) 4ms
480
- ✓  node  test/components/account/OrganizationDrawerBody.test.tsx (3 tests) 54ms
481
- ✓  node  test/components/auth/ProfileChallenge.test.tsx (3 tests) 43ms
478
+ ✓  node  test/components/account/OrganizationDrawerBody.test.tsx (3 tests) 37ms
482
479
  ✓  node  test/sdk/analytics/utils.test.ts (2 tests) 4ms
483
- ✓  node  test/sdk/account/clearPersistedSessionState.test.ts (1 test) 20ms
484
- ✓  node  test/components/quotes/MyAccountQuoteStatusBadge.test.tsx (2 tests) 25ms
485
-
486
-  Test Files  70 passed (70)
487
-  Tests  539 passed (539)
488
-  Start at  21:43:08
489
-  Duration  36.60s (transform 8.50s, setup 0ms, collect 29.72s, tests 11.44s, environment 48.51s, prepare 1.27s)
480
+ ✓  node  test/components/auth/ProfileChallenge.test.tsx (3 tests) 30ms
481
+ ✓  node  test/sdk/account/clearPersistedSessionState.test.ts (1 test) 9ms
482
+ ✓  node  test/components/quotes/MyAccountQuoteStatusBadge.test.tsx (2 tests) 23ms
483
+
484
+  Test Files  71 passed (71)
485
+  Tests  548 passed (548)
486
+  Start at  21:46:31
487
+  Duration  36.11s (transform 8.70s, setup 0ms, collect 28.71s, tests 9.95s, environment 48.82s, prepare 1.22s)
490
488
 
package/CHANGELOG.md CHANGED
@@ -3,6 +3,16 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # [4.5.0-dev.3](https://github.com/vtex/faststore/compare/v4.5.0-dev.2...v4.5.0-dev.3) (2026-07-22)
7
+
8
+ ### Features
9
+
10
+ - **core:** move recommendation session start to CMS Global Section ([8589056](https://github.com/vtex/faststore/commit/8589056046b38e5dbc4f8ce6dcc669f7a15a4d85))
11
+
12
+ # [4.5.0-dev.2](https://github.com/vtex/faststore/compare/v4.5.0-dev.1...v4.5.0-dev.2) (2026-07-20)
13
+
14
+ **Note:** Version bump only for package @faststore/core
15
+
6
16
  # [4.5.0-dev.1](https://github.com/vtex/faststore/compare/v4.5.0-dev.0...v4.5.0-dev.1) (2026-07-14)
7
17
 
8
18
  ### Bug Fixes
@@ -3,10 +3,16 @@
3
3
  "$componentKey": "RecommendationShelf",
4
4
  "$componentTitle": "Recommendation Shelf",
5
5
  "title": "Recommendation Shelf",
6
- "description": "Display a carousel of personalized product recommendations from a VTEX Recommendations campaign.",
6
+ "description": "Display a carousel of personalized product recommendations from a VTEX Recommendations campaign. Enable the shelf with the Enable recommendations toggle (off by default).",
7
7
  "type": "object",
8
8
  "required": ["campaignVrn"],
9
9
  "properties": {
10
+ "enableRecommendations": {
11
+ "title": "Enable recommendations?",
12
+ "description": "When enabled, starts the VTEX Recommendations personalization session (once per browser session) and fetches personalized products for this shelf. Off by default so stores that do not use Recommendations never hit the Recommendations API.",
13
+ "type": "boolean",
14
+ "default": false
15
+ },
10
16
  "title": {
11
17
  "title": "Title",
12
18
  "description": "Override the shelf title. If not set, the campaign title will be used.",
@@ -4434,10 +4434,16 @@
4434
4434
  "$componentKey": "RecommendationShelf",
4435
4435
  "$componentTitle": "Recommendation Shelf",
4436
4436
  "title": "Recommendation Shelf",
4437
- "description": "Display a carousel of personalized product recommendations from a VTEX Recommendations campaign.",
4437
+ "description": "Display a carousel of personalized product recommendations from a VTEX Recommendations campaign. Enable the shelf with the Enable recommendations toggle (off by default).",
4438
4438
  "type": "object",
4439
4439
  "required": ["campaignVrn"],
4440
4440
  "properties": {
4441
+ "enableRecommendations": {
4442
+ "title": "Enable recommendations?",
4443
+ "description": "When enabled, starts the VTEX Recommendations personalization session (once per browser session) and fetches personalized products for this shelf. Off by default so stores that do not use Recommendations never hit the Recommendations API.",
4444
+ "type": "boolean",
4445
+ "default": false
4446
+ },
4441
4447
  "title": {
4442
4448
  "title": "Title",
4443
4449
  "description": "Override the shelf title. If not set, the campaign title will be used.",
@@ -201,9 +201,6 @@ module.exports = {
201
201
  },
202
202
  refreshToken: false,
203
203
  scrollRestoration: false,
204
- // Opt-in flag for VTEX Recommendations. When disabled (default), the store
205
- // never starts a personalization session nor calls the Recommendations API.
206
- enableRecommendations: false,
207
204
  /** Package names to transpile (e.g. ['@vtex/components']). Use a non-empty list to enable Next.js transpilation. */
208
205
  transpilePackages: [],
209
206
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faststore/core",
3
- "version": "4.5.0-dev.1",
3
+ "version": "4.5.0-dev.3",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -71,11 +71,11 @@
71
71
  "style-loader": "^3.3.1",
72
72
  "swr": "^2.2.5",
73
73
  "use-sync-external-store": "^1.6.0",
74
- "@faststore/api": "4.5.0-dev.1",
75
- "@faststore/sdk": "4.5.0-dev.1",
76
- "@faststore/diagnostics": "4.5.0-dev.1",
77
- "@faststore/ui": "4.5.0-dev.1",
78
- "@faststore/lighthouse": "4.5.0-dev.1"
74
+ "@faststore/diagnostics": "4.5.0-dev.3",
75
+ "@faststore/lighthouse": "4.5.0-dev.3",
76
+ "@faststore/sdk": "4.5.0-dev.3",
77
+ "@faststore/api": "4.5.0-dev.3",
78
+ "@faststore/ui": "4.5.0-dev.3"
79
79
  },
80
80
  "devDependencies": {
81
81
  "@cypress/code-coverage": "^3.12.1",
package/src/Layout.tsx CHANGED
@@ -6,11 +6,10 @@ import { useStartRecommendationSession } from './sdk/analytics/hooks/useStartRec
6
6
  function Layout({ children }: PropsWithChildren) {
7
7
  const props = isValidElement(children) ? children.props : undefined
8
8
  usePageViewEvent(props)
9
- // Implemented here because the personalization session must be initiated once
10
- // per browser session on each page. Gated behind the
11
- // `experimental.enableRecommendations` flag: stores that don't opt into
12
- // Recommendations never start a session nor call the Recommendations API.
13
- useStartRecommendationSession()
9
+
10
+ // Starts the personalization session once per browser session when the page
11
+ // CMS data includes a RecommendationShelf with `enableRecommendations: true`.
12
+ useStartRecommendationSession(props)
14
13
 
15
14
  return <>{children}</>
16
15
  }
@@ -0,0 +1,163 @@
1
+ # Recommendation Shelf
2
+
3
+ CMS section that renders a carousel of personalized product recommendations from a **VTEX Recommendations** campaign. It uses the same product cards (`ProductCard`) as other store shelves; items come from a recommendation campaign (cross-sell, similar items, top sellers, personalized, and related types) instead of a search query.
4
+
5
+ - **Data source:** VTEX Recommendations BFF, exposed by `@faststore/api` through the `recommendations` GraphQL query.
6
+ - **Registration:** global CMS component (`$componentKey: "RecommendationShelf"`), available on any page via CMS.
7
+ - **Opt-in via CMS:** enable Recommendations with the **Enable recommendations?** toggle on the shelf (default `false`). No store code changes or feature flags are required.
8
+
9
+ ## Contents
10
+
11
+ - [Prerequisites](#prerequisites)
12
+ - [Setup](#setup)
13
+ - [CMS configuration](#cms-configuration)
14
+ - [Campaign types (VRN) and context](#campaign-types-vrn-and-context)
15
+ - [Runtime behavior](#runtime-behavior)
16
+ - [Code-level overrides](#code-level-overrides)
17
+ - [Placement guidelines](#placement-guidelines)
18
+ - [Privacy and cache](#privacy-and-cache)
19
+ - [Troubleshooting](#troubleshooting)
20
+
21
+ ## Prerequisites
22
+
23
+ The shelf renders products only when the following conditions are met:
24
+
25
+ 1. **Enable recommendations on the shelf.** Set **Enable recommendations?** to `true` on the Recommendation Shelf in the CMS. This opts the store into the personalization session (`startRecommendationSession` mutation, owned by Layout) and allows the shelf to fetch recommendations. Without it (default `false`), no session is started and the shelf does not fetch.
26
+
27
+ 2. **Active VTEX Recommendations campaign.** Provide a valid campaign **VRN**. Example: `vrn:recommendations:my-account:rec-persona-v2:abc123`.
28
+
29
+ 3. **Activity Flow script (optional, recommended).** Used for analytics:
30
+ - reads PDP `product:*` meta tags to capture product view events;
31
+ - reads `data-af-*` attributes on the shelf to capture impression, view, and click events.
32
+
33
+ ## Setup
34
+
35
+ ### 1. Obtain the campaign VRN
36
+
37
+ In the VTEX Recommendations admin, create or select a campaign and copy its **VRN**. The expected format is:
38
+
39
+ ```text
40
+ vrn:recommendations:<account>:<campaign-type>:<campaign-id>
41
+ ```
42
+
43
+ Supported `<campaign-type>` values are listed under [Campaign types (VRN) and context](#campaign-types-vrn-and-context).
44
+
45
+ ### 2. Add Recommendation Shelf via CMS
46
+
47
+ Add **Recommendation Shelf** to the target page (home, PDP, PLP, cart, and so on), configure at least **Campaign VRN** (required), and turn **Enable recommendations?** on.
48
+
49
+ ## CMS configuration
50
+
51
+ Schema: `cms/faststore/components/cms_component__recommendationshelf.jsonc`.
52
+
53
+ | CMS property | Key | Type | Required | Default | Description |
54
+ | --- | --- | --- | --- | --- | --- |
55
+ | Enable recommendations? | `enableRecommendations` | boolean | No | `false` | Opt-in that starts the personalization session (via Layout) and allows this shelf to fetch recommendations. |
56
+ | Title | `title` | string | No | — | Overrides the shelf title. Falls back to the campaign title. If both are empty, the heading is not rendered. |
57
+ | Campaign VRN | `campaignVrn` | string | **Yes** | — | Recommendation campaign VRN. Validated by regex (see campaign types below). |
58
+ | Items context | `itemsContext` | `PDP` \| `CART` | No | `PDP` | Source of products used as request context. Applies only to context-based campaigns. |
59
+ | **Carousel Configuration** | `carouselConfiguration` | object | No | — | Carousel settings. |
60
+ | › Items per page (desktop) | `itemsPerPageDesktop` | number | No | `4` | Items per page on desktop. |
61
+ | › Items per page (mobile) | `itemsPerPageMobile` | number | No | `2` | Items per page on mobile and tablet (≤ 768px). |
62
+ | › Carousel track variant | `variant` | `slide` \| `scroll` | No | `scroll` | Carousel navigation mode. |
63
+ | › Infinite navigation? | `infiniteMode` | boolean | No | `false` | Infinite navigation (slide variant only). |
64
+ | › Navigation controls | `controls` | `complete` \| `navigationArrows` \| `paginationBullets` | No | `complete` | Visible navigation controls. |
65
+ | **Product Card Configuration** | `productCardConfiguration` | object | No | — | Product card settings. |
66
+ | › Show discount badge? | `showDiscountBadge` | boolean | No | `true` | Shows the discount badge. |
67
+ | › Cards should be bordered? | `bordered` | boolean | No | `true` | Renders bordered cards. |
68
+
69
+ ## Campaign types (VRN) and context
70
+
71
+ The campaign type is derived from the `<campaign-type>` segment of the VRN:
72
+
73
+ | Campaign | VRN segment | Internal type | Requires product context? |
74
+ | --- | --- | --- | --- |
75
+ | Cross-Sell | `rec-cross-v2` | `CROSS_SELL` | **Yes** |
76
+ | Similar Items | `rec-similar-v2` | `SIMILAR_ITEMS` | **Yes** |
77
+ | Visual Similarity | `rec-visual-v2` | `VISUAL_SIMILARITY` | **Yes** |
78
+ | Next Interactions | `rec-next-v2` | `NEXT_INTERACTION` | **Yes** |
79
+ | Personalized | `rec-persona-v2` | `PERSONALIZED` | No |
80
+ | Top Sellers | `rec-top-items-v2` | `TOP_ITEMS` | No |
81
+ | Last Seen | `rec-last-v2` | `LAST_SEEN` | No |
82
+ | Search-Based | `rec-search-v2` | `SEARCH_BASED` | No |
83
+
84
+ **Product context (`itemsContext`):**
85
+
86
+ - `PDP` (default): uses the current product detail page product (`productGroupID`). Outside a PDP there is no product context.
87
+ - `CART`: uses the products currently in the cart (deduplicated). Intended for cross-sell on the cart page.
88
+
89
+ Context-based campaigns (cross-sell, similar items, visual similarity, next interaction) skip the request when no context products are available. Context-agnostic campaigns (top sellers, personalized, last seen, search-based) ignore `itemsContext`.
90
+
91
+ ## Runtime behavior
92
+
93
+ All recommendation work runs client-side after hydration:
94
+
95
+ 1. **Session start (CMS opt-in)** — `Layout` calls `useStartRecommendationSession(pageProps)` once. The hook checks whether any `RecommendationShelf` in the CMS data has `enableRecommendations: true` and no-ops when none do. Multiple enabled shelves on the same page still start the session only once (in-memory lock + session cookie).
96
+ 2. **User id resolution** — `useRecommendationUserId()` reads the `vtex-rec-user-id` cookie, retrying until a value is available or the retry budget is exhausted.
97
+ 3. **Request arguments** — when enabled, `getRecommendationArguments()` builds `{ userId, campaignVrn, products }` or returns `null` when the VRN is invalid, the user id is missing, or a context-based campaign has no context products. When disabled, arguments stay `null`.
98
+ 4. **Fetch** — `useRecommendations()` runs `ClientRecommendationsQuery` and returns `products`, `correlationId`, and `campaign`.
99
+ 5. **Render** — shows `ProductShelfSkeleton` while loading; returns `null` on error or empty results; otherwise renders the carousel and optional heading.
100
+ 6. **Tracking** — when correlation and campaign identifiers are present, the shelf emits `data-af-*` attributes for Activity Flow. PDP product views use `product:*` meta tags from `pages/[slug]/p.tsx`.
101
+
102
+ Related files:
103
+
104
+ - Shelf: `src/components/sections/RecommendationShelf/`
105
+ - Hook: `src/sdk/analytics/hooks/useStartRecommendationSession.ts`
106
+ - Layout: `src/Layout.tsx`
107
+
108
+ ## Code-level overrides
109
+
110
+ In addition to CMS props, the component accepts code-level overrides (not exposed in the CMS schema):
111
+
112
+ ```tsx
113
+ import { RecommendationShelf } from 'src/components/sections/RecommendationShelf'
114
+
115
+ <RecommendationShelf
116
+ enableRecommendations
117
+ campaignVrn="vrn:recommendations:my-account:rec-cross-v2:abc123"
118
+ itemsContext="PDP"
119
+ ProductCard={MyCustomCard}
120
+ mapProductToProductCard={(product, index) => ({
121
+ product,
122
+ index,
123
+ highlight: index === 0,
124
+ })}
125
+ carouselConfiguration={{ itemsPerPageDesktop: 5, variant: 'slide' }}
126
+ />
127
+ ```
128
+
129
+ - **`ProductCard`**: custom card component (defaults to the core `ProductCard`).
130
+ - **`mapProductToProductCard`**: maps each recommended product (normalized `StoreProduct`) into card props. When provided, it fully owns the card props and the default `productCardConfiguration` merge no longer applies.
131
+
132
+ Campaign types and VRN validation are defined in `vrn.ts` (`VRN_TYPE_TO_RECOMMENDATION`). Updates should be made there and mirrored in the CMS schema `pattern`.
133
+
134
+ ## Placement guidelines
135
+
136
+ As a global component, the shelf can be placed on any CMS page. Suggested placements by campaign type:
137
+
138
+ - **Home / institutional pages:** context-agnostic campaigns (top sellers, personalized, last seen, search-based).
139
+ - **PDP:** context-based campaigns with `itemsContext: PDP`.
140
+ - **Cart:** cross-sell with `itemsContext: CART`.
141
+ - **PLP:** context-agnostic campaigns.
142
+
143
+ The personalization session runs only when at least one shelf has **Enable recommendations?** turned on.
144
+
145
+ ## Privacy and cache
146
+
147
+ - The `recommendations` query uses `@cacheControl(scope: "private", sMaxAge: 120, staleWhileRevalidate: 3600)`.
148
+ - Error logs omit `recommendationArgs` (which includes `userId`) and log only non-identifying context such as `campaignVrn`.
149
+ - BFF `vtex-rec-*` cookies are forwarded to the browser through `ctx.storage.cookies`.
150
+
151
+ ## Troubleshooting
152
+
153
+ **The shelf does not render.** Check, in order:
154
+
155
+ 1. Is **Enable recommendations?** set to `true` on the shelf?
156
+ 2. Is `campaignVrn` valid and is the campaign active?
157
+ 3. Is the `vtex-rec-user-id` cookie available? Without a user id, the shelf does not fetch.
158
+ 4. For context-based campaigns, is product context available (`PDP` on a product page, or a non-empty cart for `CART`)?
159
+ 5. Did the campaign return products? An empty response renders nothing by design.
160
+
161
+ **Skeleton never resolves.** Inspect the console and the GraphQL `recommendations` response for pending or failing requests.
162
+
163
+ **No tracking events.** Confirm that the Activity Flow script is present and that PDP `product:*` meta tags and shelf `data-af-*` attributes are rendered.
@@ -4,8 +4,6 @@ import { usePDP } from '@faststore/core'
4
4
  import { ProductShelf, Carousel } from '@faststore/ui'
5
5
  import type { ProductSummary_ProductFragment } from '@generated/graphql'
6
6
 
7
- import storeConfig from 'discovery.config'
8
-
9
7
  import DefaultProductCard, {
10
8
  type ProductCardProps,
11
9
  } from 'src/components/product/ProductCard'
@@ -66,6 +64,7 @@ export function RecommendationShelf<
66
64
  >({
67
65
  title,
68
66
  campaignVrn,
67
+ enableRecommendations = false,
69
68
  itemsContext = 'PDP',
70
69
  ProductCard,
71
70
  mapProductToProductCard,
@@ -120,9 +119,9 @@ export function RecommendationShelf<
120
119
  return pdpProduct ? [pdpProduct] : []
121
120
  }, [itemsContext, cartItems, productDetailPage])
122
121
 
123
- // Gate the whole feature behind the opt-in flag: when Recommendations is
122
+ // Gate the whole feature behind the CMS opt-in: when Recommendations is
124
123
  // disabled the shelf never requests recommendations (nor renders).
125
- const recommendationArgs = storeConfig.experimental.enableRecommendations
124
+ const recommendationArgs = enableRecommendations
126
125
  ? getRecommendationArguments(campaignVrn, {
127
126
  userId,
128
127
  contextProducts,
@@ -30,6 +30,13 @@ export type RecommendationShelfProps<
30
30
  > = {
31
31
  readonly title?: string
32
32
  readonly campaignVrn: string
33
+ /**
34
+ * Opt-in for VTEX Recommendations on this shelf. When `true`, Layout starts
35
+ * the personalization session (`useStartRecommendationSession`) and this
36
+ * shelf fetches recommendations. When `false` (default), neither happens.
37
+ * @default false
38
+ */
39
+ readonly enableRecommendations?: boolean
33
40
  /**
34
41
  * Where to read the products used as context for the recommendation request:
35
42
  * - `'PDP'`: the current product detail page product.
@@ -45,7 +52,7 @@ export type RecommendationShelfProps<
45
52
  /**
46
53
  * Custom card component rendered for each recommended product. Defaults to the
47
54
  * core `ProductCard`. This is a code-level override and is not exposed through
48
- * the CMS schema (`cms_component__RecommendationShelf.jsonc`).
55
+ * the CMS schema (`cms_component__recommendationshelf.jsonc`).
49
56
  */
50
57
  readonly ProductCard?: ComponentType<TCardProps>
51
58
  /**
@@ -1,8 +1,8 @@
1
- import { useEffect } from 'react'
1
+ import { useEffect, useRef } from 'react'
2
2
 
3
3
  import { gql } from '@faststore/core/api'
4
4
 
5
- import storeConfig from 'discovery.config'
5
+ import { hasEnabledRecommendationShelf } from 'src/sdk/analytics/utils/hasEnabledRecommendationShelf'
6
6
  import { useLazyQuery } from 'src/sdk/graphql/useLazyQuery'
7
7
  import { getCookie } from 'src/utils/getCookie'
8
8
  import { retry } from 'src/utils/retry'
@@ -20,12 +20,28 @@ const startRecommendationSessionMutation = gql(`
20
20
  type StartRecommendationSessionData = { startRecommendationSession: boolean }
21
21
  type StartRecommendationSessionVariables = Record<string, never>
22
22
 
23
+ // In-memory lock so Layout re-renders (e.g. multiple RecommendationShelves
24
+ // fetching) cannot start more than one session before the cookie lands.
25
+ // Survives effect cleanup / Strict Mode remounts for this JS realm.
26
+ let hasRequestedStartRecommendationSession = false
27
+
28
+ /** @internal Test-only: clears the in-memory session-start lock. */
29
+ export function resetStartRecommendationSessionLock() {
30
+ hasRequestedStartRecommendationSession = false
31
+ }
32
+
23
33
  /**
24
34
  * Starts the anonymous recommendation/personalization session once per browser
25
35
  * session.
26
36
  *
27
- * Mounted globally from `Layout` so it runs on every page, regardless of
28
- * whether a recommendation section is present.
37
+ * Mounted from `Layout` with the current page props. Opt-in is controlled by
38
+ * the CMS boolean prop `enableRecommendations` on `RecommendationShelf`
39
+ * (default `false`): if no shelf on the page has it enabled, this hook is a
40
+ * no-op and the store never hits the Recommendations API.
41
+ *
42
+ * Multiple enabled shelves on the same page still produce a single call: the
43
+ * hook runs once from Layout and is gated by an in-memory lock plus the
44
+ * session cookie.
29
45
  *
30
46
  * The product view is no longer reported through a mutation here: the PDP now
31
47
  * exposes the product data as `<meta property="product:*">` tags, which the
@@ -33,11 +49,10 @@ type StartRecommendationSessionVariables = Record<string, never>
33
49
  *
34
50
  * All work runs client-side in effects (after hydration), so it doesn't affect
35
51
  * SSR/TTFB or Lighthouse render metrics.
36
- *
37
- * Gated behind the `experimental.enableRecommendations` flag: stores that don't
38
- * opt into Recommendations never start a session nor hit the Recommendations API.
39
52
  */
40
- export function useStartRecommendationSession() {
53
+ export function useStartRecommendationSession(pageProps?: unknown) {
54
+ const enableRecommendations = hasEnabledRecommendationShelf(pageProps)
55
+
41
56
  const [runStartRecommendationSession] = useLazyQuery<
42
57
  StartRecommendationSessionData,
43
58
  StartRecommendationSessionVariables
@@ -49,30 +64,40 @@ export function useStartRecommendationSession() {
49
64
  }
50
65
  )
51
66
 
67
+ // useLazyQuery returns a new `execute` identity every render; keep a stable
68
+ // ref so the effect does not re-subscribe (and re-fire) on unrelated updates.
69
+ const runStartRecommendationSessionRef = useRef(runStartRecommendationSession)
70
+ runStartRecommendationSessionRef.current = runStartRecommendationSession
71
+
52
72
  useEffect(() => {
53
- if (!storeConfig.experimental.enableRecommendations) {
73
+ if (!enableRecommendations) {
54
74
  return
55
75
  }
56
76
 
57
77
  const startRecommendationSessionCookie = getCookie(
58
78
  VTEX_REC_USER_START_SESSION
59
79
  )
60
-
61
80
  if (startRecommendationSessionCookie) {
62
81
  return
63
82
  }
64
83
 
65
- const controller = new AbortController()
84
+ if (hasRequestedStartRecommendationSession) {
85
+ return
86
+ }
87
+ hasRequestedStartRecommendationSession = true
66
88
 
67
89
  // The session endpoint may not be ready on the first try, so we retry with
68
90
  // exponential backoff until it returns a defined result (or we give up).
69
91
  // Transient request/GraphQL rejections are treated as retryable: swallow
70
92
  // the error and return `undefined` so the backoff loop keeps going instead
71
93
  // of aborting on the first failure.
94
+ //
95
+ // Intentionally no AbortController: cleanup would cancel the only attempt
96
+ // (Strict Mode remount / Layout re-render) while the lock stays set.
72
97
  void retry(
73
98
  async () => {
74
99
  try {
75
- return await runStartRecommendationSession({})
100
+ return await runStartRecommendationSessionRef.current({})
76
101
  } catch {
77
102
  return undefined
78
103
  }
@@ -83,14 +108,9 @@ export function useStartRecommendationSession() {
83
108
  backoff: true,
84
109
  maxDelayMs: 3000,
85
110
  until: (result) => result !== undefined,
86
- signal: controller.signal,
87
111
  }
88
112
  // Guard the discarded promise so a give-up (or any unexpected rejection)
89
113
  // never surfaces as an unhandled promise rejection.
90
114
  ).catch(() => {})
91
-
92
- return () => {
93
- controller.abort()
94
- }
95
- }, [runStartRecommendationSession])
115
+ }, [enableRecommendations])
96
116
  }
@@ -0,0 +1,49 @@
1
+ type CmsSection = {
2
+ name?: string
3
+ $componentKey?: string
4
+ data?: {
5
+ enableRecommendations?: boolean
6
+ }
7
+ }
8
+
9
+ function pushSections(target: CmsSection[], value: unknown) {
10
+ if (Array.isArray(value)) {
11
+ for (const item of value) {
12
+ if (item && typeof item === 'object') {
13
+ target.push(item as CmsSection)
14
+ }
15
+ }
16
+ return
17
+ }
18
+
19
+ if (value && typeof value === 'object') {
20
+ const sections = (value as { sections?: unknown }).sections
21
+ if (Array.isArray(sections)) {
22
+ pushSections(target, sections)
23
+ }
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Returns true when the current page CMS data includes at least one
29
+ * `RecommendationShelf` with `enableRecommendations: true`.
30
+ */
31
+ export function hasEnabledRecommendationShelf(pageProps: unknown): boolean {
32
+ if (!pageProps || typeof pageProps !== 'object') {
33
+ return false
34
+ }
35
+
36
+ const props = pageProps as Record<string, unknown>
37
+ const sections: CmsSection[] = []
38
+
39
+ pushSections(sections, props.sections)
40
+ pushSections(sections, props.globalSections)
41
+ pushSections(sections, props.page)
42
+
43
+ return sections.some(
44
+ (section) =>
45
+ (section.name === 'RecommendationShelf' ||
46
+ section.$componentKey === 'RecommendationShelf') &&
47
+ section.data?.enableRecommendations === true
48
+ )
49
+ }
@@ -47,27 +47,6 @@ vi.mock(
47
47
  })
48
48
  )
49
49
 
50
- // Keep the real store config (the cart SDK reads `storeConfig.cart` at module
51
- // init) and only override the opt-in flag, which we toggle per test.
52
- const recommendationsFlag = vi.hoisted(() => ({ enabled: true }))
53
- vi.mock('discovery.config', async (original) => {
54
- const actual = (await original()) as { default?: Record<string, unknown> }
55
- const base = (actual.default ?? actual) as Record<string, unknown>
56
- const experimental = (base.experimental ?? {}) as Record<string, unknown>
57
-
58
- return {
59
- default: {
60
- ...base,
61
- experimental: {
62
- ...experimental,
63
- get enableRecommendations() {
64
- return recommendationsFlag.enabled
65
- },
66
- },
67
- },
68
- }
69
- })
70
-
71
50
  import { RecommendationShelf } from 'src/components/sections/RecommendationShelf/RecommendationShelf'
72
51
 
73
52
  const CAMPAIGN_VRN = 'vrn:recommendations:acc:rec-top-items-v2:campaign-1'
@@ -91,7 +70,6 @@ beforeEach(() => {
91
70
  loading: false,
92
71
  })
93
72
  useRecommendationUserId.mockReturnValue('user-1')
94
- recommendationsFlag.enabled = true
95
73
  })
96
74
 
97
75
  afterEach(() => {
@@ -99,7 +77,7 @@ afterEach(() => {
99
77
  })
100
78
 
101
79
  describe('RecommendationShelf', () => {
102
- it('renders a product card per recommended product', async () => {
80
+ it('renders a product card per recommended product when enabled', async () => {
103
81
  useRecommendations.mockReturnValue({
104
82
  data: recommendationData,
105
83
  isLoading: false,
@@ -107,7 +85,7 @@ describe('RecommendationShelf', () => {
107
85
  })
108
86
 
109
87
  const { getAllByTestId, getByText } = render(
110
- <RecommendationShelf campaignVrn={CAMPAIGN_VRN} />
88
+ <RecommendationShelf campaignVrn={CAMPAIGN_VRN} enableRecommendations />
111
89
  )
112
90
 
113
91
  await waitFor(() => {
@@ -126,7 +104,9 @@ describe('RecommendationShelf', () => {
126
104
  error: null,
127
105
  })
128
106
 
129
- render(<RecommendationShelf campaignVrn={CROSS_SELL_VRN} />)
107
+ render(
108
+ <RecommendationShelf campaignVrn={CROSS_SELL_VRN} enableRecommendations />
109
+ )
130
110
 
131
111
  await waitFor(() => {
132
112
  const lastArgs = useRecommendations.mock.calls.at(-1)?.[0]
@@ -146,7 +126,11 @@ describe('RecommendationShelf', () => {
146
126
  })
147
127
 
148
128
  const { getByText } = render(
149
- <RecommendationShelf campaignVrn={CAMPAIGN_VRN} title="Custom title" />
129
+ <RecommendationShelf
130
+ campaignVrn={CAMPAIGN_VRN}
131
+ enableRecommendations
132
+ title="Custom title"
133
+ />
150
134
  )
151
135
 
152
136
  await waitFor(() => {
@@ -162,6 +146,25 @@ describe('RecommendationShelf', () => {
162
146
  error: null,
163
147
  })
164
148
 
149
+ const { container, queryByTestId } = render(
150
+ <RecommendationShelf campaignVrn={CAMPAIGN_VRN} enableRecommendations />
151
+ )
152
+
153
+ await waitFor(() => {
154
+ const lastArgs = useRecommendations.mock.calls.at(-1)?.[0]
155
+ expect(lastArgs).toBeNull()
156
+ })
157
+ expect(queryByTestId('product-card')).toBeNull()
158
+ expect(container).toBeEmptyDOMElement()
159
+ })
160
+
161
+ it('skips the fetch when recommendations are disabled (default)', async () => {
162
+ useRecommendations.mockReturnValue({
163
+ data: undefined,
164
+ isLoading: false,
165
+ error: null,
166
+ })
167
+
165
168
  const { container, queryByTestId } = render(
166
169
  <RecommendationShelf campaignVrn={CAMPAIGN_VRN} />
167
170
  )
@@ -182,7 +185,7 @@ describe('RecommendationShelf', () => {
182
185
  })
183
186
 
184
187
  const { container } = render(
185
- <RecommendationShelf campaignVrn={CAMPAIGN_VRN} />
188
+ <RecommendationShelf campaignVrn={CAMPAIGN_VRN} enableRecommendations />
186
189
  )
187
190
 
188
191
  expect(container).toBeEmptyDOMElement()
@@ -196,7 +199,7 @@ describe('RecommendationShelf', () => {
196
199
  })
197
200
 
198
201
  const { container, queryByTestId } = render(
199
- <RecommendationShelf campaignVrn={CAMPAIGN_VRN} />
202
+ <RecommendationShelf campaignVrn={CAMPAIGN_VRN} enableRecommendations />
200
203
  )
201
204
 
202
205
  expect(queryByTestId('product-card')).toBeNull()
@@ -211,25 +214,9 @@ describe('RecommendationShelf', () => {
211
214
  })
212
215
 
213
216
  const { getByTestId } = render(
214
- <RecommendationShelf campaignVrn={CAMPAIGN_VRN} />
217
+ <RecommendationShelf campaignVrn={CAMPAIGN_VRN} enableRecommendations />
215
218
  )
216
219
 
217
220
  expect(getByTestId('skeleton').getAttribute('data-loading')).toBe('true')
218
221
  })
219
-
220
- it('skips the fetch when recommendations are disabled', async () => {
221
- recommendationsFlag.enabled = false
222
- useRecommendations.mockReturnValue({
223
- data: undefined,
224
- isLoading: false,
225
- error: null,
226
- })
227
-
228
- render(<RecommendationShelf campaignVrn={CROSS_SELL_VRN} />)
229
-
230
- await waitFor(() => {
231
- const lastArgs = useRecommendations.mock.calls.at(-1)?.[0]
232
- expect(lastArgs).toBeNull()
233
- })
234
- })
235
222
  })
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { hasEnabledRecommendationShelf } from 'src/sdk/analytics/utils/hasEnabledRecommendationShelf'
4
+
5
+ describe('hasEnabledRecommendationShelf', () => {
6
+ it('returns false for empty or unrelated page props', () => {
7
+ expect(hasEnabledRecommendationShelf(undefined)).toBe(false)
8
+ expect(hasEnabledRecommendationShelf({})).toBe(false)
9
+ expect(
10
+ hasEnabledRecommendationShelf({
11
+ sections: [{ name: 'ProductShelf', data: {} }],
12
+ })
13
+ ).toBe(false)
14
+ })
15
+
16
+ it('returns true when a page section enables recommendations', () => {
17
+ expect(
18
+ hasEnabledRecommendationShelf({
19
+ sections: [
20
+ {
21
+ name: 'RecommendationShelf',
22
+ data: { enableRecommendations: true, campaignVrn: 'vrn:x' },
23
+ },
24
+ ],
25
+ })
26
+ ).toBe(true)
27
+ })
28
+
29
+ it('returns true when a global section enables recommendations', () => {
30
+ expect(
31
+ hasEnabledRecommendationShelf({
32
+ globalSections: {
33
+ sections: [
34
+ {
35
+ $componentKey: 'RecommendationShelf',
36
+ data: { enableRecommendations: true },
37
+ },
38
+ ],
39
+ },
40
+ })
41
+ ).toBe(true)
42
+ })
43
+
44
+ it('returns true when page.sections enables recommendations', () => {
45
+ expect(
46
+ hasEnabledRecommendationShelf({
47
+ page: {
48
+ sections: [
49
+ {
50
+ name: 'RecommendationShelf',
51
+ data: { enableRecommendations: true },
52
+ },
53
+ ],
54
+ },
55
+ })
56
+ ).toBe(true)
57
+ })
58
+
59
+ it('returns false when the shelf is present but enableRecommendations is false', () => {
60
+ expect(
61
+ hasEnabledRecommendationShelf({
62
+ sections: [
63
+ {
64
+ name: 'RecommendationShelf',
65
+ data: { enableRecommendations: false },
66
+ },
67
+ ],
68
+ })
69
+ ).toBe(false)
70
+ })
71
+
72
+ it('returns true when at least one of multiple shelves enables recommendations', () => {
73
+ expect(
74
+ hasEnabledRecommendationShelf({
75
+ sections: [
76
+ {
77
+ name: 'RecommendationShelf',
78
+ data: { enableRecommendations: false },
79
+ },
80
+ {
81
+ name: 'RecommendationShelf',
82
+ data: { enableRecommendations: true },
83
+ },
84
+ ],
85
+ })
86
+ ).toBe(true)
87
+ })
88
+ })
@@ -18,17 +18,50 @@ vi.mock('src/sdk/graphql/useLazyQuery', () => ({
18
18
  const getCookie = vi.hoisted(() => vi.fn())
19
19
  vi.mock('src/utils/getCookie', () => ({ getCookie }))
20
20
 
21
- const storeConfigMock = vi.hoisted(() => ({
22
- experimental: { enableRecommendations: true },
23
- }))
24
- vi.mock('discovery.config', () => ({ default: storeConfigMock }))
25
-
26
- import { useStartRecommendationSession } from 'src/sdk/analytics/hooks/useStartRecommendationSession'
21
+ import {
22
+ resetStartRecommendationSessionLock,
23
+ useStartRecommendationSession,
24
+ } from 'src/sdk/analytics/hooks/useStartRecommendationSession'
25
+
26
+ const enabledPageProps = {
27
+ sections: [
28
+ {
29
+ name: 'RecommendationShelf',
30
+ data: { enableRecommendations: true },
31
+ },
32
+ ],
33
+ }
34
+
35
+ const multipleEnabledShelvesPageProps = {
36
+ sections: [
37
+ {
38
+ name: 'RecommendationShelf',
39
+ data: { enableRecommendations: true, campaignVrn: 'vrn:a' },
40
+ },
41
+ {
42
+ name: 'RecommendationShelf',
43
+ data: { enableRecommendations: true, campaignVrn: 'vrn:b' },
44
+ },
45
+ {
46
+ name: 'ProductShelf',
47
+ data: {},
48
+ },
49
+ ],
50
+ }
51
+
52
+ const disabledPageProps = {
53
+ sections: [
54
+ {
55
+ name: 'RecommendationShelf',
56
+ data: { enableRecommendations: false },
57
+ },
58
+ ],
59
+ }
27
60
 
28
61
  beforeEach(() => {
62
+ resetStartRecommendationSessionLock()
29
63
  useLazyQueryMock.mockReturnValue([runStartRecommendationSession, {}])
30
64
  runStartRecommendationSession.mockResolvedValue(true)
31
- storeConfigMock.experimental.enableRecommendations = true
32
65
  })
33
66
 
34
67
  afterEach(() => {
@@ -36,34 +69,76 @@ afterEach(() => {
36
69
  })
37
70
 
38
71
  describe('useStartRecommendationSession', () => {
72
+ it('does not start a session when no RecommendationShelf is enabled in CMS', async () => {
73
+ getCookie.mockReturnValue(undefined)
74
+
75
+ renderHook(() => useStartRecommendationSession(disabledPageProps))
76
+
77
+ await waitFor(() => {
78
+ expect(runStartRecommendationSession).not.toHaveBeenCalled()
79
+ })
80
+ })
81
+
82
+ it('does not start a session when page props are missing', async () => {
83
+ getCookie.mockReturnValue(undefined)
84
+
85
+ renderHook(() => useStartRecommendationSession())
86
+
87
+ await waitFor(() => {
88
+ expect(runStartRecommendationSession).not.toHaveBeenCalled()
89
+ })
90
+ })
91
+
39
92
  it('does not start a session when the session cookie already exists', async () => {
40
93
  getCookie.mockReturnValue('already-started')
41
94
 
42
- renderHook(() => useStartRecommendationSession())
95
+ renderHook(() => useStartRecommendationSession(enabledPageProps))
43
96
 
44
97
  await waitFor(() => {
45
98
  expect(runStartRecommendationSession).not.toHaveBeenCalled()
46
99
  })
47
100
  })
48
101
 
49
- it('starts a session when no session cookie is present', async () => {
102
+ it('starts a session when CMS enableRecommendations is true and no session cookie is present', async () => {
50
103
  getCookie.mockReturnValue(undefined)
51
104
 
52
- renderHook(() => useStartRecommendationSession())
105
+ renderHook(() => useStartRecommendationSession(enabledPageProps))
53
106
 
54
107
  await waitFor(() => {
55
- expect(runStartRecommendationSession).toHaveBeenCalled()
108
+ expect(runStartRecommendationSession).toHaveBeenCalledTimes(1)
56
109
  })
57
110
  })
58
111
 
59
- it('does not start a session when recommendations are disabled', async () => {
60
- storeConfigMock.experimental.enableRecommendations = false
112
+ it('starts a session only once when the page has multiple enabled RecommendationShelves', async () => {
61
113
  getCookie.mockReturnValue(undefined)
62
114
 
63
- renderHook(() => useStartRecommendationSession())
115
+ const { rerender } = renderHook(
116
+ ({ pageProps }) => useStartRecommendationSession(pageProps),
117
+ { initialProps: { pageProps: multipleEnabledShelvesPageProps } }
118
+ )
119
+
120
+ // Simulate Layout re-renders caused by multiple shelves updating.
121
+ rerender({ pageProps: multipleEnabledShelvesPageProps })
122
+ rerender({ pageProps: multipleEnabledShelvesPageProps })
64
123
 
65
124
  await waitFor(() => {
66
- expect(runStartRecommendationSession).not.toHaveBeenCalled()
125
+ expect(runStartRecommendationSession).toHaveBeenCalledTimes(1)
126
+ })
127
+ })
128
+
129
+ it('does not start a second session when the hook mounts again without a cookie', async () => {
130
+ getCookie.mockReturnValue(undefined)
131
+
132
+ renderHook(() => useStartRecommendationSession(enabledPageProps))
133
+
134
+ await waitFor(() => {
135
+ expect(runStartRecommendationSession).toHaveBeenCalledTimes(1)
136
+ })
137
+
138
+ renderHook(() => useStartRecommendationSession(enabledPageProps))
139
+
140
+ await waitFor(() => {
141
+ expect(runStartRecommendationSession).toHaveBeenCalledTimes(1)
67
142
  })
68
143
  })
69
144
  })