@faststore/core 4.7.1-dev.0 → 4.7.1-dev.2

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.7.0 generate /home/runner/work/faststore/faststore/packages/core
2
+ > @faststore/core@4.7.1-dev.1 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.7.0 gen-types /home/runner/work/faststore/faststore/packages/core
6
+ > @faststore/core@4.7.1-dev.1 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.7.0 cache-graphql /home/runner/work/faststore/faststore/packages/core
22
+ > @faststore/core@4.7.1-dev.1 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,5 +1,5 @@
1
1
 
2
- > @faststore/core@4.7.0 test /home/runner/work/faststore/faststore/packages/core
2
+ > @faststore/core@4.7.1-dev.1 test /home/runner/work/faststore/faststore/packages/core
3
3
  > vitest run
4
4
 
5
5
 
@@ -7,22 +7,22 @@
7
7
 
8
8
  Not implemented: navigation to another Document
9
9
  Not implemented: navigation to another Document
10
- Browserslist: browsers data (caniuse-lite) is 6 months old. Please run:
10
+ Browserslist: browsers data (caniuse-lite) is 7 months old. Please run:
11
11
  npx update-browserslist-db@latest
12
12
  Why you should do it regularly: https://github.com/browserslist/update-db#readme
13
- ✓  browser  test/sdk/ui/useScrollRestoration.browser.test.ts (43 tests) 858ms
14
- ✓  browser  test/components/account/cards/MyAccountListCards.browser.test.tsx (14 tests) 2348ms
15
- ✓ shows the tab bar and renders Personal cards by default  824ms
16
- ✓ paginates when there are more than 25 cards  397ms
17
- ✓  browser  test/components/account/ContractSwitcher.browser.test.tsx (13 tests) 3333ms
18
- ✓ shows an error state when the list fails to load  715ms
19
- ✓ requires selecting a contract before Confirm is enabled, then switches (REQ-03)  559ms
20
- ✓ keeps Confirm disabled until a different contract is selected  440ms
21
- ✓ keeps Confirm disabled when the selected contract is filtered out  322ms
22
- ✓ does not reload when the switch returns false  353ms
23
- ✓  browser  test/sdk/localization/useBindingSelector.browser.test.tsx (9 tests) 119ms
24
- ✓  browser  test/components/ui/ProductGallery/GalleryPageHeightLock.browser.test.tsx (8 tests) 265ms
25
- ✓  browser  test/sdk/session/reconcileSessionLocale.browser.test.ts (5 tests) 40ms
13
+ ✓  browser  test/sdk/ui/useScrollRestoration.browser.test.ts (43 tests) 1209ms
14
+ ✓  browser  test/components/account/ContractSwitcher.browser.test.tsx (13 tests) 2734ms
15
+ ✓ shows an error state when the list fails to load  597ms
16
+ ✓ requires selecting a contract before Confirm is enabled, then switches (REQ-03)  367ms
17
+ ✓ keeps Confirm disabled when the selected contract is filtered out  310ms
18
+ ✓ does not reload when the switch returns false  342ms
19
+ ✓  browser  test/components/account/cards/MyAccountListCards.browser.test.tsx (14 tests) 2532ms
20
+ ✓ shows the tab bar and renders Personal cards by default  1068ms
21
+ ✓ paginates when there are more than 25 cards  345ms
22
+ ✓  browser  test/sdk/localization/useBindingSelector.browser.test.tsx (9 tests) 97ms
23
+ ✓  browser  test/sdk/session/reconcileSessionLocale.browser.test.ts (5 tests) 15ms
24
+ ✓  browser  test/components/ui/ProductGallery/GalleryPageHeightLock.browser.test.tsx (8 tests) 211ms
25
+ ✓  browser  test/sdk/recommendations/useRecommendationShelf.browser.test.tsx (12 tests) 172ms
26
26
  stderr | test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx
27
27
  Error in optimistic validation: TypeError: Failed to parse URL from /api/graphql?operationName=ValidateCartMutation&operationHash=3c71b0d3fc2bade14df68abfa271858beb361b12
28
28
   at node:internal/deps/undici/undici:16416:13
@@ -49,11 +49,10 @@ Browserslist: browsers data (caniuse-lite) is 6 months old. Please run:
49
49
  }
50
50
  }
51
51
 
52
- ✓  browser  test/sdk/recommendations/useRecommendationShelf.browser.test.tsx (12 tests) 262ms
53
52
  stderr | test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx > RecommendationShelf > renders nothing when there is an error
54
53
  Error fetching recommendations undefined boom for campaign vrn:recommendations:acc:rec-top-items-v2:campaign-1
55
54
 
56
- ✓  browser  test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx (7 tests) 365ms
55
+ ✓  browser  test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx (7 tests) 114ms
57
56
  stderr | test/pages/password-protection.browser.test.tsx > PasswordProtectionLogin > posts the password and redirects to the requested return path
58
57
  Warning: An update to PasswordProtectionLogin inside a test was not wrapped in act(...).
59
58
 
@@ -283,13 +282,14 @@ act(() => {
283
282
  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
284
283
  at PasswordProtectionLogin (/home/runner/work/faststore/faststore/packages/core/src/pages/password-protection/index.tsx:19:69)
285
284
 
286
- ✓  browser  test/pages/password-protection.browser.test.tsx (6 tests) 1247ms
287
- ✓ renders the password protection form  670ms
288
- ✓  browser  test/sdk/session/getInitialSession.browser.test.ts (6 tests) 25ms
289
- ✓  browser  test/components/account/OrganizationDrawer.browser.test.tsx (5 tests) 596ms
290
- ✓ renders the menu view with contract details and manage footer  329ms
285
+ ✓  browser  test/pages/password-protection.browser.test.tsx (6 tests) 1132ms
286
+ ✓ renders the password protection form  466ms
287
+ ✓  browser  test/sdk/session/getInitialSession.browser.test.ts (6 tests) 18ms
288
+ ✓  browser  test/components/account/OrganizationDrawer.browser.test.tsx (5 tests) 1348ms
289
+ ✓ renders the menu view with contract details and manage footer  744ms
290
+ ✓ opens the contract switcher and returns to the menu on back  304ms
291
291
  ✓  browser  test/sdk/account/changeContractToken.browser.test.ts (9 tests) 24ms
292
- stderr | test/components/cms/RenderSections.browser.test.tsx
292
+ stderr | test/sdk/localization/store-url.browser.test.ts
293
293
  Error in optimistic validation: TypeError: Cannot read properties of undefined (reading 'read')
294
294
  at waitForSessionValidated (/home/runner/work/faststore/faststore/packages/core/src/sdk/cart/waitForSessionValidated.ts:16:32)
295
295
  at validateCart (/home/runner/work/faststore/faststore/packages/core/src/sdk/cart/index.ts:120:9)
@@ -297,8 +297,8 @@ This ensures that you're testing the behavior the user would see in the browser.
297
297
  at a (/home/runner/work/faststore/faststore/packages/sdk/dist/es/index.mjs:354:23)
298
298
   at processTicksAndRejections (node:internal/process/task_queues:103:5)
299
299
 
300
- ✓  browser  test/components/cms/RenderSections.browser.test.tsx (5 tests) 80ms
301
- stderr | test/sdk/localization/store-url.browser.test.ts
300
+ ✓  browser  test/sdk/localization/store-url.browser.test.ts (3 tests) 5ms
301
+ stderr | test/components/cms/RenderSections.browser.test.tsx
302
302
  Error in optimistic validation: TypeError: Cannot read properties of undefined (reading 'read')
303
303
  at waitForSessionValidated (/home/runner/work/faststore/faststore/packages/core/src/sdk/cart/waitForSessionValidated.ts:16:32)
304
304
  at validateCart (/home/runner/work/faststore/faststore/packages/core/src/sdk/cart/index.ts:120:9)
@@ -306,11 +306,11 @@ This ensures that you're testing the behavior the user would see in the browser.
306
306
  at a (/home/runner/work/faststore/faststore/packages/sdk/dist/es/index.mjs:354:23)
307
307
   at processTicksAndRejections (node:internal/process/task_queues:103:5)
308
308
 
309
- ✓  browser  test/sdk/localization/store-url.browser.test.ts (3 tests) 5ms
310
- ✓  browser  test/sdk/account/useAvailableContracts.browser.test.ts (6 tests) 37ms
311
- ✓  browser  test/components/templates/SearchPage/SearchWrapper.browser.test.tsx (3 tests) 42ms
312
- ✓  browser  test/sdk/recommendations/useRecommendationShelf.sessionFallback.browser.test.tsx (2 tests) 86ms
313
- ✓  browser  test/sdk/account/useSwitchContract.browser.test.ts (4 tests) 35ms
309
+ ✓  browser  test/sdk/account/useAvailableContracts.browser.test.ts (6 tests) 36ms
310
+ ✓  browser  test/components/cms/RenderSections.browser.test.tsx (5 tests) 65ms
311
+ ✓  browser  test/components/templates/SearchPage/SearchWrapper.browser.test.tsx (3 tests) 35ms
312
+ ✓  browser  test/sdk/recommendations/useRecommendationShelf.sessionFallback.browser.test.tsx (2 tests) 126ms
313
+ ✓  browser  test/sdk/account/useSwitchContract.browser.test.ts (4 tests) 39ms
314
314
  stderr | test/components/account/doLogout.browser.test.ts
315
315
  Error in optimistic validation: TypeError: Cannot read properties of undefined (reading 'read')
316
316
  at waitForSessionValidated (/home/runner/work/faststore/faststore/packages/core/src/sdk/cart/waitForSessionValidated.ts:16:32)
@@ -319,9 +319,10 @@ This ensures that you're testing the behavior the user would see in the browser.
319
319
  at a (/home/runner/work/faststore/faststore/packages/sdk/dist/es/index.mjs:354:23)
320
320
   at processTicksAndRejections (node:internal/process/task_queues:103:5)
321
321
 
322
- ✓  browser  test/components/account/doLogout.browser.test.ts (1 test) 33ms
323
- ✓  browser  test/components/ui/LocalizationButton.browser.test.tsx (3 tests) 47ms
324
- ✓  browser  test/components/sections/Account/AccountListCards.browser.test.tsx (3 tests) 71ms
322
+ ✓  browser  test/components/account/doLogout.browser.test.ts (1 test) 23ms
323
+ ✓  browser  test/components/ui/LocalizationButton.browser.test.tsx (3 tests) 45ms
324
+ ✓  browser  test/components/sections/Account/AccountListCards.browser.test.tsx (3 tests) 72ms
325
+ ✓  browser  test/components/account/OrganizationDrawerHeader.browser.test.tsx (3 tests) 250ms
325
326
  stderr | test/sdk/account/clearPersistedSessionState.browser.test.ts
326
327
  Error in optimistic validation: TypeError: Cannot read properties of undefined (reading 'read')
327
328
  at waitForSessionValidated (/home/runner/work/faststore/faststore/packages/core/src/sdk/cart/waitForSessionValidated.ts:16:32)
@@ -330,15 +331,11 @@ This ensures that you're testing the behavior the user would see in the browser.
330
331
  at a (/home/runner/work/faststore/faststore/packages/sdk/dist/es/index.mjs:354:23)
331
332
   at processTicksAndRejections (node:internal/process/task_queues:103:5)
332
333
 
333
- ✓  browser  test/sdk/account/clearPersistedSessionState.browser.test.ts (1 test) 10ms
334
- ✓  browser  test/components/account/OrganizationDrawerHeader.browser.test.tsx (3 tests) 336ms
335
- ✓  browser  test/components/account/OrganizationDrawerFooter.browser.test.tsx (3 tests) 296ms
336
- ✓  browser  test/utils/isLocalHost.browser.test.ts (3 tests) 6ms
337
- ✓  browser  test/sdk/session/reconcileSessionLocale.disabled.browser.test.ts (1 test) 2ms
338
- ✓  node  test/utils/localization/bindingPaths.test.ts (71 tests) 33ms
339
- ✓  browser  test/components/skeletons/ProductGridSkeleton.browser.test.tsx (2 tests) 31ms
340
- ✓  node  test/server/password-protection-service.test.ts (22 tests) 34ms
341
- ✓  node  test/utils/match-url.test.ts (10 tests) 13ms
334
+ ✓  browser  test/sdk/account/clearPersistedSessionState.browser.test.ts (1 test) 8ms
335
+ ✓  browser  test/components/account/OrganizationDrawerFooter.browser.test.tsx (3 tests) 301ms
336
+ ✓  browser  test/utils/isLocalHost.browser.test.ts (3 tests) 4ms
337
+ ✓  browser  test/sdk/session/reconcileSessionLocale.disabled.browser.test.ts (1 test) 3ms
338
+ ✓  node  test/utils/localization/bindingPaths.test.ts (71 tests) 32ms
342
339
  stderr | test/sdk/account/refreshToken.browser.test.ts
343
340
  Error in optimistic validation: TypeError: Cannot read properties of undefined (reading 'read')
344
341
  at waitForSessionValidated (/home/runner/work/faststore/faststore/packages/core/src/sdk/cart/waitForSessionValidated.ts:16:32)
@@ -347,10 +344,13 @@ This ensures that you're testing the behavior the user would see in the browser.
347
344
  at a (/home/runner/work/faststore/faststore/packages/sdk/dist/es/index.mjs:354:23)
348
345
   at processTicksAndRejections (node:internal/process/task_queues:103:5)
349
346
 
350
- ✓  browser  test/sdk/account/refreshToken.browser.test.ts (1 test) 5ms
351
- ✓  node  test/sdk/localization/useBindingSelector.test.tsx (22 tests) 16ms
352
- ✓  node  test/pages/api/unlock.test.ts (15 tests) 179ms
353
- ✓  node  test/sdk/localization/bindingSelector.test.ts (18 tests) 11ms
347
+ ✓  browser  test/components/skeletons/ProductGridSkeleton.browser.test.tsx (2 tests) 34ms
348
+ ✓  browser  test/sdk/account/refreshToken.browser.test.ts (1 test) 6ms
349
+ ✓  node  test/server/password-protection-service.test.ts (22 tests) 31ms
350
+ ✓  node  test/utils/match-url.test.ts (10 tests) 9ms
351
+ ✓  node  test/pages/api/unlock.test.ts (15 tests) 265ms
352
+ ✓  node  test/sdk/localization/useBindingSelector.test.tsx (22 tests) 11ms
353
+ ✓  node  test/sdk/localization/bindingSelector.test.ts (18 tests) 10ms
354
354
  stderr | test/components/search/SearchInputComponent.test.tsx
355
355
  Warning: forwardRef render functions accept exactly two parameters: props and ref. Did you forget to use the ref parameter?
356
356
 
@@ -415,17 +415,18 @@ Warning: React does not recognize the `providerProps` prop on a DOM element. If
415
415
  at Wrapper (/home/runner/work/faststore/faststore/packages/core/test/components/search/SearchInputComponent.test.tsx:106:20)
416
416
 
417
417
  ✓  node  test/components/search/SearchInputComponent.test.tsx (6 tests) 73ms
418
- ✓  node  test/sdk/search/useSearchHistory.test.ts (13 tests) 56ms
419
- ✓  node  test/utils/cookieCacheBusting.test.ts (10 tests) 14ms
418
+ ✓  node  test/sdk/search/useSearchHistory.test.ts (13 tests) 44ms
419
+ ✓  node  test/utils/cookieCacheBusting.test.ts (10 tests) 15ms
420
420
  stderr | test/pages/pvt/account/cards/index.test.ts > Cards page getServerSideProps > redirects to /pvt/account/403 when the query fails with 401/403
421
421
  { extensions: { status: 401 } }
422
422
 
423
423
  stderr | test/pages/pvt/account/cards/index.test.ts > Cards page getServerSideProps > renders the error state (no redirect) when the query fails with a non-auth status
424
424
  { extensions: { status: 502 } }
425
425
 
426
- ✓  node  test/pages/pvt/account/cards/index.test.ts (8 tests) 14ms
427
- ✓  node  test/utils/clearCookies.test.ts (20 tests) 21ms
426
+ ✓  node  test/pages/pvt/account/cards/index.test.ts (8 tests) 8ms
427
+ ✓  node  test/utils/clearCookies.test.ts (20 tests) 26ms
428
428
  ✓  node  test/experimental/myAccountCmsServerSideProps.test.ts (5 tests) 7ms
429
+ ✓  node  test/components/quotes/MyAccountListQuotesTable.test.tsx (12 tests) 398ms
429
430
  stderr | test/pages/api/graphql.test.ts > /api/graphql error status propagation > propagates the upstream 400 from a wrapped BadRequestError instead of 500
430
431
  Graphql execution returned with error: [
431
432
  {
@@ -474,7 +475,7 @@ Warning: React does not recognize the `providerProps` prop on a DOM element. If
474
475
 
475
476
  stderr | test/pages/api/graphql.test.ts > /api/graphql request handling > maps BadRequestError thrown by execute to its status
476
477
  Something unexpected occurred querying Graphql endpoint:
477
- T [FastStoreError]: malformed
478
+ E [FastStoreError]: malformed
478
479
  at /home/runner/work/faststore/faststore/packages/core/test/pages/api/graphql.test.ts:190:31
479
480
  at /home/runner/work/faststore/faststore/packages/core/test/pages/api/graphql.test.ts:196:39
480
481
  at file:///home/runner/work/faststore/faststore/node_modules/.pnpm/@vitest+runner@4.0.18/node_modules/@vitest/runner/dist/index.js:1024:60
@@ -490,7 +491,7 @@ Warning: React does not recognize the `providerProps` prop on a DOM element. If
490
491
 
491
492
  stderr | test/pages/api/graphql.test.ts > /api/graphql request handling > maps UnauthorizedError thrown by execute to its status
492
493
  Something unexpected occurred querying Graphql endpoint:
493
- re [FastStoreError]: expired
494
+ ie [FastStoreError]: expired
494
495
  at /home/runner/work/faststore/faststore/packages/core/test/pages/api/graphql.test.ts:191:33
495
496
  at /home/runner/work/faststore/faststore/packages/core/test/pages/api/graphql.test.ts:196:39
496
497
  at file:///home/runner/work/faststore/faststore/node_modules/.pnpm/@vitest+runner@4.0.18/node_modules/@vitest/runner/dist/index.js:1024:60
@@ -518,68 +519,68 @@ Warning: React does not recognize the `providerProps` prop on a DOM element. If
518
519
  at file:///home/runner/work/faststore/faststore/node_modules/.pnpm/@vitest+runner@4.0.18/node_modules/@vitest/runner/dist/index.js:1653:37
519
520
  at Traces.$ (file:///home/runner/work/faststore/faststore/node_modules/.pnpm/vitest@4.0.18_@opentelemetry+api@1.9.0_@types+node@20.19.13_jiti@2.6.1_jsdom@27.4.0_sas_13be941c57d55f08f06eeaf1c180ddfe/node_modules/vitest/dist/chunks/traces.CCmnQaNT.js:142:27)
520
521
 
521
- ✓  node  test/pages/api/graphql.test.ts (17 tests) 34ms
522
- ✓  node  test/components/quotes/MyAccountListQuotesTable.test.tsx (12 tests) 415ms
523
- ✓  node  test/components/quotes/MyAccountListQuotes.test.tsx (9 tests) 820ms
524
- ✓ navigates with the search term on blur  364ms
525
- ✓ navigates without a label param when the search is cleared  361ms
522
+ ✓  node  test/pages/api/graphql.test.ts (17 tests) 33ms
526
523
  stderr | test/sdk/account/useSetPassword.test.ts > useSetPassword > maps wrongcredentials error responses to the existing message
527
524
  Set password request failed: Bad Request
528
525
 
529
- ✓  node  test/sdk/account/useSetPassword.test.ts (6 tests) 39ms
530
- ✓  node  test/sdk/orderEntry/useOrderEntryOperation.test.ts (8 tests) 36ms
531
- ✓  node  test/components/quotes/quoteFilters.test.ts (23 tests) 42ms
532
- ✓  node  test/utils/multipleTemplates.test.ts (8 tests) 7ms
533
- ✓  node  test/utils/localization/localizeRedirectDestination.test.ts (11 tests) 6ms
534
- ✓  node  test/sdk/account/getMyAccountRoutes.test.ts (12 tests) 7ms
535
- ✓  node  test/sdk/orderEntry/useOrderEntryUpload.test.ts (8 tests) 52ms
536
- ✓  node  test/sdk/redirects/index.test.ts (7 tests) 54ms
537
- ✓  node  test/components/quotes/MyAccountQuotesFilterSlider.test.tsx (5 tests) 155ms
538
- ✓  node  test/utils/productJsonLd.test.ts (17 tests) 12ms
539
- ✓  node  test/proxy.localization.test.ts (5 tests) 32ms
540
- ✓  node  test/server/index.test.ts (7 tests) 850ms
541
- ✓ should handle options and execute  348ms
542
- ✓  node  test/sdk/orderEntry/useOrderEntry.test.ts (8 tests) 49ms
543
- ✓  node  test/sdk/session/syncSalesChannelFromOrderForm.test.ts (6 tests) 7ms
544
- ✓  node  test/components/quotes/MyAccountQuotesSelectedTags.test.tsx (8 tests) 119ms
545
- ✓  node  test/sdk/analytics/useStartRecommendationSession.test.ts (6 tests) 48ms
546
- ✓  node  test/proxy.test.ts (4 tests) 32ms
547
- ✓  node  test/server/webops-api.test.ts (9 tests) 18ms
548
- ✓  node  test/sdk/orderEntry/useOrderFormItems.test.ts (7 tests) 42ms
549
- ✓  node  test/sdk/cart/waitForSessionValidated.test.ts (5 tests) 13ms
550
- ✓  node  test/server/cms/myAccountDefaultSections.test.ts (7 tests) 7ms
526
+ ✓  node  test/sdk/account/useSetPassword.test.ts (6 tests) 29ms
527
+ ✓  node  test/components/quotes/MyAccountListQuotes.test.tsx (9 tests) 833ms
528
+ ✓ navigates with the search term on blur  366ms
529
+ ✓ navigates without a label param when the search is cleared  358ms
530
+ ✓  node  test/components/quotes/quoteFilters.test.ts (23 tests) 25ms
531
+ ✓  node  test/utils/multipleTemplates.test.ts (8 tests) 6ms
532
+ ✓  node  test/sdk/orderEntry/useOrderEntryOperation.test.ts (8 tests) 44ms
533
+ ✓  node  test/utils/localization/localizeRedirectDestination.test.ts (11 tests) 5ms
534
+ ✓  node  test/utils/localization/hreflang.test.ts (8 tests) 6ms
535
+ ✓  node  test/sdk/account/getMyAccountRoutes.test.ts (12 tests) 8ms
536
+ ✓  node  test/sdk/orderEntry/useOrderEntryUpload.test.ts (8 tests) 61ms
537
+ ✓  node  test/sdk/redirects/index.test.ts (7 tests) 80ms
538
+ ✓  node  test/components/quotes/MyAccountQuotesFilterSlider.test.tsx (5 tests) 154ms
539
+ ✓  node  test/utils/productJsonLd.test.ts (17 tests) 10ms
540
+ ✓  node  test/proxy.localization.test.ts (5 tests) 37ms
541
+ ✓  node  test/sdk/orderEntry/useOrderEntry.test.ts (8 tests) 29ms
542
+ ✓  node  test/server/index.test.ts (7 tests) 939ms
543
+ ✓ should handle options and execute  391ms
544
+ ✓  node  test/sdk/session/syncSalesChannelFromOrderForm.test.ts (6 tests) 6ms
545
+ ✓  node  test/components/quotes/MyAccountQuotesSelectedTags.test.tsx (8 tests) 99ms
546
+ ✓  node  test/sdk/analytics/useStartRecommendationSession.test.ts (6 tests) 42ms
547
+ ✓  node  test/sdk/orderEntry/useOrderFormItems.test.ts (7 tests) 36ms
548
+ ✓  node  test/server/webops-api.test.ts (9 tests) 19ms
549
+ ✓  node  test/proxy.test.ts (4 tests) 28ms
550
+ ✓  node  test/sdk/cart/waitForSessionValidated.test.ts (5 tests) 12ms
551
+ ✓  node  test/sdk/localization/store-url.test.ts (4 tests) 4ms
552
+ ✓  node  test/server/cms/myAccountDefaultSections.test.ts (7 tests) 6ms
551
553
  ✓  node  test/server/content/service.test.ts (5 tests) 5ms
552
- ✓  node  test/utils/productJsonLdRendering.test.tsx (4 tests) 19ms
553
- ✓  node  test/sdk/account/refreshToken.test.ts (5 tests) 8ms
554
- ✓  node  test/server/cms/global.test.ts (3 tests) 6ms
555
- ✓  node  test/sdk/localization/store-url.test.ts (1 test) 4ms
556
- ✓  node  test/sdk/auth/useAuth.test.ts (5 tests) 31ms
557
- ✓  node  test/components/quotes/MyAccountQuotesStatusSelector.test.tsx (6 tests) 155ms
558
- ✓  node  test/utils/getRequestHostname.test.ts (12 tests) 6ms
559
- ✓  node  test/pages/api/preview.test.ts (2 tests) 9ms
560
- ✓  node  test/server/rootSpan.test.ts (4 tests) 50ms
554
+ ✓  node  test/utils/productJsonLdRendering.test.tsx (4 tests) 14ms
555
+ ✓  node  test/sdk/account/refreshToken.test.ts (5 tests) 6ms
556
+ ✓  node  test/server/cms/global.test.ts (3 tests) 5ms
557
+ ✓  node  test/sdk/auth/useAuth.test.ts (5 tests) 35ms
558
+ ✓  node  test/components/quotes/MyAccountQuotesStatusSelector.test.tsx (6 tests) 144ms
559
+ ✓  node  test/utils/getRequestHostname.test.ts (12 tests) 7ms
560
+ ✓  node  test/server/rootSpan.test.ts (4 tests) 40ms
561
+ ✓  node  test/pages/api/preview.test.ts (2 tests) 8ms
562
+ ✓  node  test/components/search/SearchInput.test.ts (6 tests) 8ms
561
563
  ✓  node  test/utils/validateSessionRefreshToken.test.ts (6 tests) 5ms
562
- ✓  node  test/components/account/Layout.test.tsx (6 tests) 77ms
563
- ✓  node  test/components/search/SearchInput.test.ts (6 tests) 9ms
564
- ✓  node  test/sdk/recommendations/vrn.test.ts (5 tests) 7ms
565
- ✓  node  test/utils/retry.test.ts (5 tests) 15ms
564
+ ✓  node  test/components/account/Layout.test.tsx (6 tests) 59ms
565
+ ✓  node  test/sdk/recommendations/vrn.test.ts (5 tests) 5ms
566
+ ✓  node  test/utils/retry.test.ts (5 tests) 14ms
566
567
  ✓  node  test/server/cms/index.test.ts (2 tests) 5ms
567
568
  ✓  node  test/sdk/account/getB2BSessionClaims.test.ts (4 tests) 5ms
568
- ✓  node  test/utils/unlockResponse.test.ts (11 tests) 5ms
569
+ ✓  node  test/utils/unlockResponse.test.ts (11 tests) 6ms
569
570
  ✓  node  test/sdk/recommendations/useRecommendations.test.ts (2 tests) 5ms
570
- ✓  node  test/sdk/recommendations/useRecommendationUserId.test.ts (4 tests) 192ms
571
- ✓  node  test/utils/isLocalHost.test.ts (7 tests) 6ms
571
+ ✓  node  test/sdk/recommendations/useRecommendationUserId.test.ts (4 tests) 181ms
572
+ ✓  node  test/utils/isLocalHost.test.ts (7 tests) 5ms
573
+ ✓  node  test/components/account/OrganizationDrawerBody.test.tsx (3 tests) 60ms
572
574
  ✓  node  test/utils/userOrderStatus.test.ts (3 tests) 4ms
573
- ✓  node  test/components/account/OrganizationDrawerBody.test.tsx (3 tests) 42ms
574
575
  ✓  node  test/utils/getCookie.test.ts (4 tests) 5ms
575
- ✓  node  test/sdk/analytics/utils.test.ts (2 tests) 5ms
576
+ ✓  node  test/sdk/analytics/utils.test.ts (2 tests) 4ms
576
577
  ✓  node  test/components/auth/ProfileChallenge.test.tsx (3 tests) 29ms
577
- ✓  node  test/instrumentation.test.ts (3 tests) 5ms
578
+ ✓  node  test/instrumentation.test.ts (3 tests) 7ms
578
579
  ✓  node  test/sdk/account/clearPersistedSessionState.test.ts (1 test) 11ms
579
- ✓  node  test/components/quotes/MyAccountQuoteStatusBadge.test.tsx (2 tests) 25ms
580
+ ✓  node  test/components/quotes/MyAccountQuoteStatusBadge.test.tsx (2 tests) 27ms
580
581
 
581
-  Test Files  93 passed (93)
582
-  Tests  742 passed (742)
583
-  Start at  21:32:47
584
-  Duration  45.76s (transform 11.27s, setup 0ms, import 38.81s, tests 14.72s, environment 57.88s)
582
+  Test Files  94 passed (94)
583
+  Tests  753 passed (753)
584
+  Start at  16:48:59
585
+  Duration  46.17s (transform 12.82s, setup 0ms, import 40.03s, tests 14.85s, environment 56.78s)
585
586
 
package/@generated/gql.ts CHANGED
@@ -29,7 +29,7 @@ type Documents = {
29
29
  "\n fragment ServerCollectionPage on Query {\n collection(slug: $slug) {\n id\n }\n }\n": typeof types.ServerCollectionPageFragmentDoc,
30
30
  "\n fragment ServerProduct on Query {\n product(locator: $locator) {\n id: productID\n }\n }\n": typeof types.ServerProductFragmentDoc,
31
31
  "\n query ServerAccountPageQuery {\n accountProfile {\n name\n }\n }\n": typeof types.ServerAccountPageQueryDocument,
32
- "\n query ServerCollectionPageQuery($slug: String!) {\n ...ServerCollectionPage\n collection(slug: $slug) {\n seo {\n title\n description\n }\n breadcrumbList {\n itemListElement {\n item\n name\n position\n }\n }\n meta {\n selectedFacets {\n key\n value\n }\n }\n otherLocales {\n locale\n slug\n }\n }\n }\n": typeof types.ServerCollectionPageQueryDocument,
32
+ "\n query ServerCollectionPageQuery($slug: String!) {\n ...ServerCollectionPage\n collection(slug: $slug) {\n seo {\n title\n description\n }\n breadcrumbList {\n itemListElement {\n item\n name\n position\n }\n }\n meta {\n selectedFacets {\n key\n value\n }\n }\n otherLocales {\n locale\n slug\n }\n canonicalSlug\n }\n }\n": typeof types.ServerCollectionPageQueryDocument,
33
33
  "\n query ServerProductQuery($locator: [IStoreSelectedFacet!]!) {\n ...ServerProduct\n product(locator: $locator) {\n id: productID\n\n seo {\n title\n description\n canonical\n }\n\n brand {\n name\n }\n\n sku\n gtin\n mpn\n name\n description\n releaseDate\n\n breadcrumbList {\n itemListElement {\n item\n name\n position\n }\n }\n\n image {\n url\n alternateName\n }\n\n offers {\n lowPrice\n highPrice\n lowPriceWithTaxes\n priceCurrency\n offers {\n availability\n price\n priceValidUntil\n priceCurrency\n itemCondition\n priceToken\n seller {\n identifier\n }\n }\n }\n\n isVariantOf {\n productGroupID\n }\n\n otherLocales {\n locale\n slug\n }\n\n defaultLocaleSlug\n\n ...ProductDetailsFragment_product\n }\n }\n": typeof types.ServerProductQueryDocument,
34
34
  "\n query ServerListCardsQuery {\n listCreditCards {\n list {\n accountId\n bin\n cardNumber\n cardLabel\n paymentSystem\n paymentSystemName\n isDefault\n isActive\n origin\n }\n }\n accountProfile {\n name\n }\n hasAdHocCardAccess\n }\n": typeof types.ServerListCardsQueryDocument,
35
35
  "\n fragment UserOrderItemsFragment on UserOrderItems {\n id\n name\n quantity\n sellingPrice\n unitMultiplier\n measurementUnit\n imageUrl\n detailUrl\n refId\n rewardValue\n }\n": typeof types.UserOrderItemsFragmentFragmentDoc,
@@ -82,7 +82,7 @@ const documents: Documents = {
82
82
  "\n fragment ServerCollectionPage on Query {\n collection(slug: $slug) {\n id\n }\n }\n": types.ServerCollectionPageFragmentDoc,
83
83
  "\n fragment ServerProduct on Query {\n product(locator: $locator) {\n id: productID\n }\n }\n": types.ServerProductFragmentDoc,
84
84
  "\n query ServerAccountPageQuery {\n accountProfile {\n name\n }\n }\n": types.ServerAccountPageQueryDocument,
85
- "\n query ServerCollectionPageQuery($slug: String!) {\n ...ServerCollectionPage\n collection(slug: $slug) {\n seo {\n title\n description\n }\n breadcrumbList {\n itemListElement {\n item\n name\n position\n }\n }\n meta {\n selectedFacets {\n key\n value\n }\n }\n otherLocales {\n locale\n slug\n }\n }\n }\n": types.ServerCollectionPageQueryDocument,
85
+ "\n query ServerCollectionPageQuery($slug: String!) {\n ...ServerCollectionPage\n collection(slug: $slug) {\n seo {\n title\n description\n }\n breadcrumbList {\n itemListElement {\n item\n name\n position\n }\n }\n meta {\n selectedFacets {\n key\n value\n }\n }\n otherLocales {\n locale\n slug\n }\n canonicalSlug\n }\n }\n": types.ServerCollectionPageQueryDocument,
86
86
  "\n query ServerProductQuery($locator: [IStoreSelectedFacet!]!) {\n ...ServerProduct\n product(locator: $locator) {\n id: productID\n\n seo {\n title\n description\n canonical\n }\n\n brand {\n name\n }\n\n sku\n gtin\n mpn\n name\n description\n releaseDate\n\n breadcrumbList {\n itemListElement {\n item\n name\n position\n }\n }\n\n image {\n url\n alternateName\n }\n\n offers {\n lowPrice\n highPrice\n lowPriceWithTaxes\n priceCurrency\n offers {\n availability\n price\n priceValidUntil\n priceCurrency\n itemCondition\n priceToken\n seller {\n identifier\n }\n }\n }\n\n isVariantOf {\n productGroupID\n }\n\n otherLocales {\n locale\n slug\n }\n\n defaultLocaleSlug\n\n ...ProductDetailsFragment_product\n }\n }\n": types.ServerProductQueryDocument,
87
87
  "\n query ServerListCardsQuery {\n listCreditCards {\n list {\n accountId\n bin\n cardNumber\n cardLabel\n paymentSystem\n paymentSystemName\n isDefault\n isActive\n origin\n }\n }\n accountProfile {\n name\n }\n hasAdHocCardAccess\n }\n": types.ServerListCardsQueryDocument,
88
88
  "\n fragment UserOrderItemsFragment on UserOrderItems {\n id\n name\n quantity\n sellingPrice\n unitMultiplier\n measurementUnit\n imageUrl\n detailUrl\n refId\n rewardValue\n }\n": types.UserOrderItemsFragmentFragmentDoc,
@@ -180,7 +180,7 @@ export function gql(source: "\n query ServerAccountPageQuery {\n accountProf
180
180
  /**
181
181
  * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
182
182
  */
183
- export function gql(source: "\n query ServerCollectionPageQuery($slug: String!) {\n ...ServerCollectionPage\n collection(slug: $slug) {\n seo {\n title\n description\n }\n breadcrumbList {\n itemListElement {\n item\n name\n position\n }\n }\n meta {\n selectedFacets {\n key\n value\n }\n }\n otherLocales {\n locale\n slug\n }\n }\n }\n"): typeof import('./graphql').ServerCollectionPageQueryDocument;
183
+ export function gql(source: "\n query ServerCollectionPageQuery($slug: String!) {\n ...ServerCollectionPage\n collection(slug: $slug) {\n seo {\n title\n description\n }\n breadcrumbList {\n itemListElement {\n item\n name\n position\n }\n }\n meta {\n selectedFacets {\n key\n value\n }\n }\n otherLocales {\n locale\n slug\n }\n canonicalSlug\n }\n }\n"): typeof import('./graphql').ServerCollectionPageQueryDocument;
184
184
  /**
185
185
  * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
186
186
  */
@@ -1345,13 +1345,24 @@ export type StoreCartMessage = {
1345
1345
  export type StoreCollection = {
1346
1346
  /** List of items consisting of chain linked web pages, ending with the current page. */
1347
1347
  breadcrumbList: StoreBreadcrumbList;
1348
+ /**
1349
+ * Slug the catalog resolves for the requested locale, lowercased.
1350
+ * Meant for the page's canonical URL: `slug` echoes the path the shopper
1351
+ * visited, and by-linkid matches case-insensitively, so without this every
1352
+ * casing variant would claim to be canonical. Unlike `otherLocales`, it may fall
1353
+ * back to a slug registered for another locale, so it must never be used to
1354
+ * build hreflang annotations.
1355
+ */
1356
+ canonicalSlug: Maybe<Scalars['String']['output']>;
1348
1357
  /** Collection ID. */
1349
1358
  id: Scalars['ID']['output'];
1350
1359
  /** Collection meta information. Used for search. */
1351
1360
  meta: StoreCollectionMeta;
1352
1361
  /**
1353
1362
  * Localized versions of this collection for all available locales.
1354
- * Only populated when localization is enabled.
1363
+ * Only announces slugs the catalog has registered for each locale, so it is safe
1364
+ * to build hreflang annotations from. Only populated when localization is
1365
+ * enabled.
1355
1366
  */
1356
1367
  otherLocales: Maybe<Array<StoreCollectionLocale>>;
1357
1368
  /** Meta tag data. */
@@ -2820,7 +2831,7 @@ export type ServerCollectionPageQueryQueryVariables = Exact<{
2820
2831
  }>;
2821
2832
 
2822
2833
 
2823
- export type ServerCollectionPageQueryQuery = { collection: { id: string, seo: { title: string, description: string }, breadcrumbList: { itemListElement: Array<{ item: string, name: string, position: number }> }, meta: { selectedFacets: Array<{ key: string, value: string }> }, otherLocales: Array<{ locale: string, slug: string }> | null } };
2834
+ export type ServerCollectionPageQueryQuery = { collection: { canonicalSlug: string | null, id: string, seo: { title: string, description: string }, breadcrumbList: { itemListElement: Array<{ item: string, name: string, position: number }> }, meta: { selectedFacets: Array<{ key: string, value: string }> }, otherLocales: Array<{ locale: string, slug: string }> | null } };
2824
2835
 
2825
2836
  export type ServerProductQueryQueryVariables = Exact<{
2826
2837
  locator: Array<IStoreSelectedFacet> | IStoreSelectedFacet;
@@ -3624,7 +3635,7 @@ export const SearchEvent_MetadataFragmentDoc = new TypedDocumentString(`
3624
3635
  }
3625
3636
  `, {"fragmentName":"SearchEvent_metadata"}) as unknown as TypedDocumentString<SearchEvent_MetadataFragment, unknown>;
3626
3637
  export const ServerAccountPageQueryDocument = {"__meta__":{"operationName":"ServerAccountPageQuery","operationHash":"9baae331b75848a310fecb457e8c971ae27897ff"}} as unknown as TypedDocumentString<ServerAccountPageQueryQuery, ServerAccountPageQueryQueryVariables>;
3627
- export const ServerCollectionPageQueryDocument = {"__meta__":{"operationName":"ServerCollectionPageQuery","operationHash":"d46841b30ae1f6350021b5cf02f253d56c848664"}} as unknown as TypedDocumentString<ServerCollectionPageQueryQuery, ServerCollectionPageQueryQueryVariables>;
3638
+ export const ServerCollectionPageQueryDocument = {"__meta__":{"operationName":"ServerCollectionPageQuery","operationHash":"fa5f52d2e0c1c7905c1d8572b81ce48d62265a14"}} as unknown as TypedDocumentString<ServerCollectionPageQueryQuery, ServerCollectionPageQueryQueryVariables>;
3628
3639
  export const ServerProductQueryDocument = {"__meta__":{"operationName":"ServerProductQuery","operationHash":"13fc9efa8628b91971e3a8aa985e6cdbed70adae"}} as unknown as TypedDocumentString<ServerProductQueryQuery, ServerProductQueryQueryVariables>;
3629
3640
  export const ServerListCardsQueryDocument = {"__meta__":{"operationName":"ServerListCardsQuery","operationHash":"392cf85d18d66b94d6ea8d6b2a3c6ffb5c94683d"}} as unknown as TypedDocumentString<ServerListCardsQueryQuery, ServerListCardsQueryQueryVariables>;
3630
3641
  export const ServerOrderDetailsQueryDocument = {"__meta__":{"operationName":"ServerOrderDetailsQuery","operationHash":"bdf677bbccce12186a5ef15aebdce46585a99782"}} as unknown as TypedDocumentString<ServerOrderDetailsQueryQuery, ServerOrderDetailsQueryQueryVariables>;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "9baae331b75848a310fecb457e8c971ae27897ff": "query ServerAccountPageQuery { accountProfile { name } }",
3
- "d46841b30ae1f6350021b5cf02f253d56c848664": "fragment ServerCollectionPage on Query { collection(slug: $slug) { id } } query ServerCollectionPageQuery($slug: String!) { collection(slug: $slug) { breadcrumbList { itemListElement { item name position } } meta { selectedFacets { key value } } otherLocales { locale slug } seo { description title } } ...ServerCollectionPage }",
3
+ "fa5f52d2e0c1c7905c1d8572b81ce48d62265a14": "fragment ServerCollectionPage on Query { collection(slug: $slug) { id } } query ServerCollectionPageQuery($slug: String!) { collection(slug: $slug) { breadcrumbList { itemListElement { item name position } } canonicalSlug meta { selectedFacets { key value } } otherLocales { locale slug } seo { description title } } ...ServerCollectionPage }",
4
4
  "13fc9efa8628b91971e3a8aa985e6cdbed70adae": "fragment CartProductItem on StoreProduct { additionalProperty { name propertyID value valueReference } brand { name } gtin image { alternateName url } isVariantOf { name productGroupID skuVariants { activeVariations availableVariations slugsMap } } name sku unitMultiplier } fragment ProductDetailsFragment_product on StoreProduct { additionalProperty { name propertyID value valueReference } brand { name } description gtin image { alternateName url } isVariantOf { name productGroupID skuVariants { activeVariations allVariantProducts { name productID } availableVariations slugsMap } } name offers { lowPrice lowPriceWithTaxes offers { availability listPrice listPriceWithTaxes price priceToken priceWithTaxes quantity seller { identifier } } } id: productID sku unitMultiplier ...CartProductItem } fragment ServerProduct on Query { product(locator: $locator) { id: productID } } query ServerProductQuery($locator: [IStoreSelectedFacet!]!) { product(locator: $locator) { brand { name } breadcrumbList { itemListElement { item name position } } defaultLocaleSlug description gtin image { alternateName url } isVariantOf { productGroupID } mpn name offers { highPrice lowPrice lowPriceWithTaxes offers { availability itemCondition price priceCurrency priceToken priceValidUntil seller { identifier } } priceCurrency } otherLocales { locale slug } id: productID releaseDate seo { canonical description title } sku ...ProductDetailsFragment_product } ...ServerProduct }",
5
5
  "392cf85d18d66b94d6ea8d6b2a3c6ffb5c94683d": "query ServerListCardsQuery { accountProfile { name } hasAdHocCardAccess listCreditCards { list { accountId bin cardLabel cardNumber isActive isDefault origin paymentSystem paymentSystemName } } }",
6
6
  "bdf677bbccce12186a5ef15aebdce46585a99782": "query ServerOrderDetailsQuery($orderId: String!) { accountProfile { name } userOrder(orderId: $orderId) { allowCancellation budgetData { budgets { allocations { id linkedEntity { id } reservations } balance { remaining } id name } } canProcessOrderAuthorization clientProfileData { corporateName email firstName isCorporate lastName phone } creationDate customFields { fields { name refId value } id type } deliveryOptionsData { contact { email name phone } deliveryOptions { address { addressId addressType city complement country entityId geoCoordinates neighborhood number postalCode receiverName reference state street versionId } deliveryChannel deliveryCompany deliveryWindow { endDateUtc price startDateUtc } friendlyDeliveryOptionName friendlyShippingEstimate items { id imageUrl name price quantity sellingPrice tax taxPriceTagsTotal total uniqueId } pickupStoreInfo { additionalInfo address { addressId addressType city complement country entityId geoCoordinates neighborhood number postalCode receiverName reference state street versionId } dockId friendlyName isPickupStore } quantityOfDifferentItems selectedSla seller shippingEstimate shippingEstimateDate total } } orderId paymentData { transactions { isActive payments { bankIssuedInvoiceIdentificationNumber connectorResponses { authId } group id installments lastDigits paymentOrigin paymentSystemName redemptionCode referenceValue tid url value } } } ruleForAuthorization { dimensionId orderAuthorizationId rule { authorizationData { authorizers { authorizationDate email id type } requireAllApprovals } authorizedEmails doId id isUserAuthorized isUserNextAuthorizer name notification priority scoreInterval { accept deny } status timeout trigger { condition { conditionType description expression greatherThan lessThan } effect { description effectType funcPath } } } } shopper { email firstName lastName phone } status statusDescription storePreferencesData { currencyCode } totals { id name value } } }",
@@ -1330,9 +1330,20 @@ type StoreCollection {
1330
1330
  type: StoreCollectionType!
1331
1331
  """
1332
1332
  Localized versions of this collection for all available locales.
1333
- Only populated when localization is enabled.
1333
+ Only announces slugs the catalog has registered for each locale, so it is safe
1334
+ to build hreflang annotations from. Only populated when localization is
1335
+ enabled.
1334
1336
  """
1335
1337
  otherLocales: [StoreCollectionLocale!]
1338
+ """
1339
+ Slug the catalog resolves for the requested locale, lowercased.
1340
+ Meant for the page's canonical URL: `slug` echoes the path the shopper
1341
+ visited, and by-linkid matches case-insensitively, so without this every
1342
+ casing variant would claim to be canonical. Unlike `otherLocales`, it may fall
1343
+ back to a slug registered for another locale, so it must never be used to
1344
+ build hreflang annotations.
1345
+ """
1346
+ canonicalSlug: String
1336
1347
  }
1337
1348
 
1338
1349
  """
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.7.1-dev.2](https://github.com/vtex/faststore/compare/v4.7.1-dev.1...v4.7.1-dev.2) (2026-09-02)
7
+
8
+ **Note:** Version bump only for package @faststore/core
9
+
10
+ ## [4.7.1-dev.1](https://github.com/vtex/faststore/compare/v4.7.1-dev.0...v4.7.1-dev.1) (2026-09-01)
11
+
12
+ ### Bug Fixes
13
+
14
+ - point localized canonical and hreflang at the rendered locale ([#3470](https://github.com/vtex/faststore/issues/3470)) ([d9038cf](https://github.com/vtex/faststore/commit/d9038cf631cf40eae5b9d9e3eb2c33290eba7b73))
15
+
6
16
  ## [4.7.1-dev.0](https://github.com/vtex/faststore/compare/v4.7.0...v4.7.1-dev.0) (2026-08-31)
7
17
 
8
18
  **Note:** Version bump only for package @faststore/core
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faststore/core",
3
- "version": "4.7.1-dev.0",
3
+ "version": "4.7.1-dev.2",
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.7.1-dev.0",
75
- "@faststore/diagnostics": "4.7.1-dev.0",
76
- "@faststore/lighthouse": "4.7.1-dev.0",
77
- "@faststore/sdk": "4.7.1-dev.0",
78
- "@faststore/ui": "4.7.1-dev.0"
74
+ "@faststore/api": "4.7.1-dev.2",
75
+ "@faststore/lighthouse": "4.7.1-dev.2",
76
+ "@faststore/ui": "4.7.1-dev.2",
77
+ "@faststore/diagnostics": "4.7.1-dev.2",
78
+ "@faststore/sdk": "4.7.1-dev.2"
79
79
  },
80
80
  "devDependencies": {
81
81
  "@cypress/code-coverage": "^3.12.1",
@@ -1,4 +1,5 @@
1
1
  import { NextSeo, SiteLinksSearchBoxJsonLd } from 'next-seo'
2
+ import { useRouter } from 'next/router'
2
3
  import type { ComponentType } from 'react'
3
4
 
4
5
  import { default as GLOBAL_COMPONENTS } from 'src/components/cms/global/Components'
@@ -53,6 +54,7 @@ export default function LandingPage({
53
54
  globalSections,
54
55
  globalSettings,
55
56
  }: LandingPageProps) {
57
+ const router = useRouter()
56
58
  const context = {
57
59
  data: serverData,
58
60
  globalSettings,
@@ -63,7 +65,7 @@ export default function LandingPage({
63
65
  storeConfig.seo.titleTemplate ??
64
66
  storeConfig.seo.title
65
67
 
66
- const storeURL = getStoreURL()
68
+ const storeURL = getStoreURL(router.locale)
67
69
 
68
70
  return (
69
71
  <>
@@ -24,6 +24,7 @@ import { faststoreLoader } from 'src/components/ui/Image/loader'
24
24
  import { LocalizedProductProvider } from 'src/sdk/localization/LocalizedProductContext'
25
25
  import ProductListing from './ProductListing'
26
26
  import { getStoreURL } from 'src/sdk/localization/useLocalizationConfig'
27
+ import { buildHreflangLinks } from 'src/utils/localization/hreflang'
27
28
 
28
29
  export type ProductListingPageProps = {
29
30
  data: ServerCollectionPageQueryQuery & ServerManyProductsQueryQuery
@@ -105,10 +106,22 @@ export default function ProductListingPage({
105
106
  collection?.seo.description || // Use description that comes from the Checkout API
106
107
  plpSeo?.descriptionTemplate?.replace(/%s/g, () => title) || // Use description template from the SEO config for PLP
107
108
  storeSeo.description // Use default description from the store SEO config
108
- const storeURL = getStoreURL()
109
-
109
+ const otherLocales = server.collection?.otherLocales
110
+ const storeURL = getStoreURL(router.locale).replace(/\/$/, '')
111
+
112
+ // The slug the catalog resolves, not the one the shopper typed: by-linkid
113
+ // matches case-insensitively, so /Apparel and /apparel are the same page and
114
+ // must not each claim to be canonical. `otherLocales` is deliberately not
115
+ // used here — it only carries registered translations, so a page reached
116
+ // through a locale with no translation of its own would have no canonical at
117
+ // all. Falls back to the visited path when the backend resolves no slug.
118
+ const canonicalSlug = server.collection?.canonicalSlug
110
119
  const [pathname] = router.asPath.split('?')
111
- const canonical = `${storeURL}${pathname}`
120
+ const canonical = canonicalSlug
121
+ ? `${storeURL}/${canonicalSlug}`
122
+ : `${storeURL}${pathname}`
123
+
124
+ const hreflangLinks = buildHreflangLinks(otherLocales, router.locale)
112
125
  const itemsPerPage = settings?.productGallery?.itemsPerPage ?? ITEMS_PER_PAGE
113
126
 
114
127
  let itemListElements = collection?.breadcrumbList.itemListElement ?? []
@@ -143,10 +156,7 @@ export default function ProductListingPage({
143
156
  : undefined
144
157
 
145
158
  return (
146
- <LocalizedProductProvider
147
- otherLocales={server.collection?.otherLocales}
148
- urlSuffix=""
149
- >
159
+ <LocalizedProductProvider otherLocales={otherLocales} urlSuffix="">
150
160
  <SearchProvider
151
161
  onChange={applySearchState}
152
162
  itemsPerPage={itemsPerPage}
@@ -169,6 +179,7 @@ export default function ProductListingPage({
169
179
  description={description}
170
180
  titleTemplate={titleTemplate}
171
181
  canonical={canonical}
182
+ languageAlternates={hreflangLinks}
172
183
  openGraph={{
173
184
  type: 'website',
174
185
  title,
@@ -110,6 +110,7 @@ const query = gql(`
110
110
  locale
111
111
  slug
112
112
  }
113
+ canonicalSlug
113
114
  }
114
115
  }
115
116
  `)
@@ -3,6 +3,7 @@ import deepmerge from 'deepmerge'
3
3
  import type { GetStaticPaths, GetStaticProps } from 'next'
4
4
  import { BreadcrumbJsonLd, NextSeo, ProductJsonLd } from 'next-seo'
5
5
  import Head from 'next/head'
6
+ import { useRouter } from 'next/router'
6
7
  import type { ComponentType } from 'react'
7
8
 
8
9
  import { gql } from '@generated'
@@ -28,6 +29,7 @@ import { useSession } from 'src/sdk/session'
28
29
  import { execute } from 'src/server'
29
30
  import { getComponentKey } from 'src/utils/cms'
30
31
  import { getChannelForLocale } from 'src/utils/localization/bindingPaths'
32
+ import { buildHreflangLinks } from 'src/utils/localization/hreflang'
31
33
  import { toProductJsonLdOffer } from 'src/utils/productJsonLd'
32
34
 
33
35
  import storeConfig from 'discovery.config'
@@ -97,46 +99,6 @@ const overwriteMerge = (_: any[], sourceArray: any[]) => sourceArray
97
99
  const isClientOfferEnabled = (storeConfig as StoreConfig).experimental
98
100
  .enableClientOffer
99
101
 
100
- function buildHreflangLinks(
101
- storeConfig: StoreConfig,
102
- otherLocales: Array<{ locale: string; slug: string }> | null | undefined
103
- ): Array<{ rel: string; hrefLang: string; href: string }> {
104
- if (!storeConfig.localization?.enabled || !otherLocales?.length) return []
105
-
106
- const locales = storeConfig.localization.locales
107
- const baseStoreUrl = storeConfig.storeUrl.replace(/\/$/, '')
108
- const defaultLocale = storeConfig.localization.defaultLocale
109
- const links: Array<{ rel: string; hrefLang: string; href: string }> = []
110
-
111
- for (const { locale, slug } of otherLocales) {
112
- const bindingUrl = locales?.[locale]?.bindings?.[0]?.url
113
- if (typeof bindingUrl === 'string' && bindingUrl.length > 0) {
114
- links.push({
115
- rel: 'alternate',
116
- hrefLang: locale,
117
- href: `${bindingUrl.replace(/\/$/, '')}/${slug}/p`,
118
- })
119
- }
120
- }
121
-
122
- // PREMISE: the default locale is served at the store root (no locale prefix),
123
- // which is how Next.js i18n sub-path routing works — the `defaultLocale` has no
124
- // prefix while other locales live under `/{locale}`. Hence `x-default` points to
125
- // `${storeUrl}/{slug}/p`. If a store ever serves its default locale under a path
126
- // prefix or a dedicated domain instead of the root, this href must be derived
127
- // from that locale's binding URL instead of `storeUrl`.
128
- const defaultEntry = otherLocales.find((e) => e.locale === defaultLocale)
129
- if (defaultEntry) {
130
- links.push({
131
- rel: 'alternate',
132
- hrefLang: 'x-default',
133
- href: `${baseStoreUrl}/${defaultEntry.slug}/p`,
134
- })
135
- }
136
-
137
- return links
138
- }
139
-
140
102
  // With client-side offer enabled, `useOffer` only refreshes price aggregates —
141
103
  // it does not rebuild the per-seller array the buy button reads. Keep that array
142
104
  // from SSG, but inject the fresh Pricing Fallback token into the best offer so the
@@ -162,6 +124,7 @@ function Page({
162
124
  meta,
163
125
  }: Props) {
164
126
  const { currency } = useSession()
127
+ const router = useRouter()
165
128
 
166
129
  const { product } = server
167
130
  const {
@@ -197,15 +160,16 @@ function Page({
197
160
 
198
161
  // hreflang alternate links for multi-locale stores
199
162
  const hreflangLinks = buildHreflangLinks(
200
- storeConfig as StoreConfig,
201
- server.product.otherLocales
163
+ server.product.otherLocales,
164
+ router.locale,
165
+ '/p'
202
166
  )
203
167
 
204
168
  let itemListElements = product.breadcrumbList.itemListElement ?? []
205
169
  if (itemListElements.length !== 0) {
206
170
  itemListElements = itemListElements.map(
207
171
  ({ item: pathname, name, position }) => {
208
- const pageUrl = getStoreURL() + pathname
172
+ const pageUrl = getStoreURL(router.locale) + pathname
209
173
 
210
174
  return { name, position, item: pageUrl }
211
175
  }
@@ -314,7 +278,7 @@ function Page({
314
278
  ]
315
279
  : []),
316
280
  ]}
317
- additionalLinkTags={hreflangLinks}
281
+ languageAlternates={hreflangLinks}
318
282
  titleTemplate={titleTemplate}
319
283
  />
320
284
 
@@ -512,7 +476,10 @@ export const getStaticProps: GetStaticProps<
512
476
  const { seo } = data.product
513
477
  const title = seo.title
514
478
  const description = seo.description
515
- const canonical = `${getStoreURL()}${seo.canonical}`
479
+ // Without the locale, static rendering has no request to match a binding
480
+ // against and falls back to the default locale, so a localized PDP would
481
+ // point its canonical at the default locale's prefix.
482
+ const canonical = `${getStoreURL(locale).replace(/\/$/, '')}${seo.canonical}`
516
483
 
517
484
  const meta = { title, description, canonical }
518
485
 
@@ -1,5 +1,6 @@
1
1
  import type { GetStaticProps } from 'next'
2
2
  import { NextSeo, OrganizationJsonLd, SiteLinksSearchBoxJsonLd } from 'next-seo'
3
+ import { useRouter } from 'next/router'
3
4
 
4
5
  import RenderSections from 'src/components/cms/RenderSections'
5
6
  import type { PageContentType } from 'src/server/cms'
@@ -28,6 +29,7 @@ function Page({
28
29
  globalSections: globalSectionsProp,
29
30
  serverData,
30
31
  }: Props) {
32
+ const router = useRouter()
31
33
  const { sections: globalSections, settings: globalSettings } =
32
34
  globalSectionsProp ?? {}
33
35
  const context = {
@@ -53,7 +55,7 @@ function Page({
53
55
  {} as Record<string, string>
54
56
  )
55
57
 
56
- const storeUrl = getStoreURL()
58
+ const storeUrl = getStoreURL(router.locale)
57
59
 
58
60
  return (
59
61
  <>
@@ -28,6 +28,38 @@ export function getSettingsFromConfig(
28
28
  }
29
29
  }
30
30
 
31
+ /**
32
+ * Resolves a locale's settings from configuration alone, without a request URL.
33
+ *
34
+ * Statically rendered pages have no request to match a binding against, so
35
+ * `getSettings` falls back to the default locale on the server and every
36
+ * server-rendered URL ends up describing the default locale rather than the page
37
+ * being built. Next resolves the locale for us during rendering, and this maps
38
+ * it back to a binding.
39
+ *
40
+ * The binding tie-break matches the rest of the localization layer (see
41
+ * `getChannelForLocale`): the one marked default, else the first declared.
42
+ * Returns null when the locale is unknown or has no usable binding, leaving the
43
+ * caller on its existing fallback.
44
+ */
45
+ export function getSettingsForLocale(
46
+ locale?: string
47
+ ): LocalizationSettings | null {
48
+ if (!locale || !config.localization?.enabled) return null
49
+
50
+ const localeConfig = config.localization.locales?.[locale]
51
+
52
+ if (!localeConfig) return null
53
+
54
+ const binding =
55
+ localeConfig.bindings?.find((el) => el.isDefault) ??
56
+ localeConfig.bindings?.at(0)
57
+
58
+ if (!binding) return null
59
+
60
+ return getSettingsFromConfig(localeConfig, binding)
61
+ }
62
+
31
63
  export function getSettings(params?: {
32
64
  url?: string | URL
33
65
  }): LocalizationSettings {
@@ -2,7 +2,11 @@ import config from 'discovery.config'
2
2
  import deepEqual from 'fast-deep-equal'
3
3
  import React, { useEffect, useRef, useState } from 'react'
4
4
  import { sessionStore } from '../session'
5
- import { type LocalizationSettings, getSettings } from './settings'
5
+ import {
6
+ type LocalizationSettings,
7
+ getSettings,
8
+ getSettingsForLocale,
9
+ } from './settings'
6
10
 
7
11
  export const useLocalizationConfig = (params?: { url?: string | URL }) => {
8
12
  const defaultConfig = config.localization.locales[
@@ -66,13 +70,22 @@ export const useLocalizationConfig = (params?: { url?: string | URL }) => {
66
70
  type ConfigType =
67
71
  (typeof config)['localization']['locales'][keyof (typeof config)['localization']['locales']]
68
72
 
69
- export const getStoreURL = () => {
73
+ /**
74
+ * Base URL of the storefront the current page belongs to.
75
+ *
76
+ * @param locale - Locale being rendered, as resolved by Next. Required for
77
+ * anything a crawler reads (canonical, hreflang, JSON-LD): without it, server
78
+ * rendering has no `window.location` to match a binding against and silently
79
+ * falls back to the default locale's binding, so a localized page would
80
+ * advertise URLs under the default locale's prefix.
81
+ */
82
+ export const getStoreURL = (locale?: string) => {
70
83
  // If localization is not enabled, use storeUrl from config
71
84
  if (!config.localization?.enabled === true) {
72
85
  return config.storeUrl
73
86
  }
74
87
 
75
- return getSettings().storeURL
88
+ return getSettingsForLocale(locale)?.storeURL ?? getSettings().storeURL
76
89
  }
77
90
 
78
91
  /** @description receives an react component and returns a new component with the storeURL as optional */
@@ -0,0 +1,73 @@
1
+ import storeConfig from 'discovery.config'
2
+ import { getSettingsForLocale } from 'src/sdk/localization/settings'
3
+ import { getStoreURL } from 'src/sdk/localization/useLocalizationConfig'
4
+
5
+ export type LocaleSlug = { locale: string; slug: string }
6
+
7
+ export type HreflangLink = { hrefLang: string; href: string }
8
+
9
+ /**
10
+ * Builds the hreflang cluster for a page the catalog resolves in more than one
11
+ * locale.
12
+ *
13
+ * `otherLocales` carries registered translations only, so a page reached through
14
+ * a locale that never registered a slug for it is missing from its own cluster.
15
+ * Search engines ignore a set that does not reference itself, which would make
16
+ * those annotations inert while still being reported as errors, so the cluster
17
+ * is dropped entirely rather than published without a self-reference.
18
+ *
19
+ * Feed the result to NextSeo's `languageAlternates`, not `additionalLinkTags`:
20
+ * the latter keys tags by href, so the default locale and `x-default` share a
21
+ * key and Next drops one of them.
22
+ *
23
+ * @param otherLocales - Slug registered for each locale that resolves this page
24
+ * @param currentLocale - Locale being rendered. Next leaves it undefined only
25
+ * when i18n is off, which means a single locale: the default one
26
+ * @param urlSuffix - Appended after the slug: '/p' for products, '' for collections
27
+ */
28
+ export function buildHreflangLinks(
29
+ otherLocales: LocaleSlug[] | null | undefined,
30
+ currentLocale: string | undefined,
31
+ urlSuffix = ''
32
+ ): HreflangLink[] {
33
+ if (!storeConfig.localization?.enabled || !otherLocales?.length) return []
34
+
35
+ const defaultLocale = storeConfig.localization.defaultLocale
36
+ const renderedLocale = currentLocale ?? defaultLocale
37
+
38
+ if (!otherLocales.some(({ locale }) => locale === renderedLocale)) return []
39
+
40
+ const links: HreflangLink[] = otherLocales
41
+ .filter(({ locale }) => {
42
+ const settings = getSettingsForLocale(locale)
43
+ if (!settings) {
44
+ console.warn(
45
+ `[hreflang] locale "${locale}" has no usable binding in discoveryConfig — skipped`
46
+ )
47
+ }
48
+ return settings !== null
49
+ })
50
+ .map(({ locale, slug }) => ({
51
+ hrefLang: locale,
52
+ href: `${getStoreURL(locale).replace(/\/$/, '')}/${slug}${urlSuffix}`,
53
+ }))
54
+
55
+ // x-default is what a shopper gets when no advertised locale matches, so it
56
+ // has to be a URL that is canonical in its own right. That is the default
57
+ // locale's binding, not the store root: a store whose default locale is served
58
+ // under a prefix or its own domain answers at the root too, but that root URL
59
+ // canonicalizes elsewhere and would send the cluster to a page that disclaims
60
+ // itself.
61
+ const defaultEntry = otherLocales.find(
62
+ ({ locale }) => locale === defaultLocale
63
+ )
64
+
65
+ if (defaultEntry) {
66
+ links.push({
67
+ hrefLang: 'x-default',
68
+ href: `${getStoreURL(defaultLocale).replace(/\/$/, '')}/${defaultEntry.slug}${urlSuffix}`,
69
+ })
70
+ }
71
+
72
+ return links
73
+ }
@@ -93,4 +93,20 @@ describe('localization store url', () => {
93
93
  config.localization.locales['pt-BR'].bindings[0].url
94
94
  )
95
95
  })
96
+
97
+ // Static rendering has no window.location to match a binding against, so
98
+ // without a locale every page describes itself with the default locale's URL.
99
+ it('returns the requested locale binding instead of the default one', () => {
100
+ expect(getStoreURL('en-US')).toEqual('https://brandless.fast.store/en-US')
101
+ })
102
+
103
+ it('prefers the binding marked default over declaration order', () => {
104
+ expect(getStoreURL('pt-BR')).toEqual('https://brandless.myvtex.com')
105
+ })
106
+
107
+ it('falls back to the default locale for a locale that is not configured', () => {
108
+ expect(getStoreURL('fr-FR')).toEqual(
109
+ config.localization.locales['pt-BR'].bindings[0].url
110
+ )
111
+ })
96
112
  })
@@ -0,0 +1,149 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { buildHreflangLinks } from '../../../src/utils/localization/hreflang.js'
3
+
4
+ vi.mock(import('../../../discovery.config.js'), async (original) => ({
5
+ default: {
6
+ ...((await original()).default ?? {}),
7
+ localization: {
8
+ enabled: true,
9
+ defaultLocale: 'en-US',
10
+ regions: {},
11
+ locales: {
12
+ 'en-US': {
13
+ code: 'en-US',
14
+ bindings: [
15
+ {
16
+ currencyCode: 'USD',
17
+ // The default locale is served under a prefix, not at the root.
18
+ url: 'https://brandless.fast.store/en-US',
19
+ salesChannel: '1',
20
+ isDefault: true,
21
+ },
22
+ ],
23
+ },
24
+ 'pt-BR': {
25
+ code: 'pt-BR',
26
+ bindings: [
27
+ {
28
+ currencyCode: 'BRL',
29
+ url: 'https://brandless.fast.store/pt-BR',
30
+ salesChannel: '2',
31
+ isDefault: true,
32
+ },
33
+ ],
34
+ },
35
+ 'it-IT': {
36
+ code: 'it-IT',
37
+ bindings: [
38
+ {
39
+ currencyCode: 'USD',
40
+ // Bindings are arbitrary paths, not locale codes.
41
+ url: 'https://brandless.fast.store/europe/it',
42
+ salesChannel: '1',
43
+ isDefault: true,
44
+ },
45
+ ],
46
+ },
47
+ },
48
+ currencies: {
49
+ USD: { code: 'USD', name: 'US Dollar', symbol: '$' },
50
+ BRL: { code: 'BRL', name: 'Brazilian Real', symbol: 'R$' },
51
+ },
52
+ },
53
+ },
54
+ }))
55
+
56
+ const OTHER_LOCALES = [
57
+ { locale: 'en-US', slug: 'eletronicos' },
58
+ { locale: 'pt-BR', slug: 'eletronicos' },
59
+ { locale: 'it-IT', slug: 'elettronica' },
60
+ ]
61
+
62
+ describe('buildHreflangLinks', () => {
63
+ it('points each locale at its own binding', () => {
64
+ const links = buildHreflangLinks(OTHER_LOCALES, 'en-US')
65
+
66
+ expect(links).toContainEqual({
67
+ hrefLang: 'pt-BR',
68
+ href: 'https://brandless.fast.store/pt-BR/eletronicos',
69
+ })
70
+ expect(links).toContainEqual({
71
+ hrefLang: 'it-IT',
72
+ href: 'https://brandless.fast.store/europe/it/elettronica',
73
+ })
74
+ })
75
+
76
+ // A cluster that does not reference itself is ignored by search engines.
77
+ it('includes the rendered locale itself', () => {
78
+ const links = buildHreflangLinks(OTHER_LOCALES, 'en-US')
79
+
80
+ expect(links).toContainEqual({
81
+ hrefLang: 'en-US',
82
+ href: 'https://brandless.fast.store/en-US/eletronicos',
83
+ })
84
+ })
85
+
86
+ // The store root answers too, but it canonicalizes elsewhere, so pointing
87
+ // x-default at it would send the cluster to a page that disclaims itself.
88
+ it('derives x-default from the default locale binding, not the store root', () => {
89
+ const links = buildHreflangLinks(OTHER_LOCALES, 'en-US')
90
+
91
+ expect(links).toContainEqual({
92
+ hrefLang: 'x-default',
93
+ href: 'https://brandless.fast.store/en-US/eletronicos',
94
+ })
95
+ })
96
+
97
+ it('appends the product suffix when given one', () => {
98
+ const links = buildHreflangLinks(
99
+ [{ locale: 'pt-BR', slug: 'camisa-12' }],
100
+ 'pt-BR',
101
+ '/p'
102
+ )
103
+
104
+ expect(links).toContainEqual({
105
+ hrefLang: 'pt-BR',
106
+ href: 'https://brandless.fast.store/pt-BR/camisa-12/p',
107
+ })
108
+ })
109
+
110
+ it('omits x-default when the default locale has no slug registered', () => {
111
+ const links = buildHreflangLinks(
112
+ [{ locale: 'pt-BR', slug: 'eletronicos' }],
113
+ 'pt-BR'
114
+ )
115
+
116
+ expect(links.some(({ hrefLang }) => hrefLang === 'x-default')).toBe(false)
117
+ })
118
+
119
+ // The catalog resolves the page in it-IT by falling back to another locale's
120
+ // slug, so the shopper sees it, but it-IT registered no translation and is
121
+ // therefore missing from the cluster it would be annotating.
122
+ it('annotates nothing when the rendered locale is not in the cluster', () => {
123
+ const links = buildHreflangLinks(
124
+ [
125
+ { locale: 'en-US', slug: 'eletronicos' },
126
+ { locale: 'pt-BR', slug: 'eletronicos' },
127
+ ],
128
+ 'it-IT'
129
+ )
130
+
131
+ expect(links).toEqual([])
132
+ })
133
+
134
+ // Next only leaves the locale undefined when i18n is off, which means the
135
+ // store has a single locale and it is the default one.
136
+ it('falls back to the default locale when Next reports none', () => {
137
+ const links = buildHreflangLinks(OTHER_LOCALES, undefined)
138
+
139
+ expect(links).toContainEqual({
140
+ hrefLang: 'en-US',
141
+ href: 'https://brandless.fast.store/en-US/eletronicos',
142
+ })
143
+ })
144
+
145
+ it('annotates nothing when there are no alternates', () => {
146
+ expect(buildHreflangLinks(null, 'en-US')).toEqual([])
147
+ expect(buildHreflangLinks([], 'en-US')).toEqual([])
148
+ })
149
+ })