@faststore/core 4.7.0-dev.2 → 4.7.0-dev.4

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.
@@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react'
2
2
 
3
3
  import { gql } from '@faststore/core/api'
4
4
 
5
- import { hasEnabledRecommendationShelf } from 'src/sdk/analytics/utils/hasEnabledRecommendationShelf'
5
+ import storeConfig from 'discovery.config'
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,9 +20,9 @@ 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.
23
+ // In-memory lock so Layout re-renders / shelf fallback cannot start more than
24
+ // one session before the cookie lands. Survives effect cleanup / Strict Mode
25
+ // remounts for this JS realm.
26
26
  let hasRequestedStartRecommendationSession = false
27
27
 
28
28
  /** @internal Test-only: clears the in-memory session-start lock. */
@@ -34,14 +34,14 @@ export function resetStartRecommendationSessionLock() {
34
34
  * Starts the anonymous recommendation/personalization session once per browser
35
35
  * session.
36
36
  *
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.
37
+ * Mounted from `Layout` (gated by `experimental.enableRecommendations`) so it
38
+ * runs on every page when the store opts in via discovery config. Also used as
39
+ * a fallback from `RecommendationShelf` when the flag is off and `userId` is
40
+ * missing (including while lookup is still pending) — pass `enabled` to
41
+ * override the feature flag for that call site.
41
42
  *
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.
43
+ * Multiple callers still produce a single mutation: the in-memory lock plus the
44
+ * session cookie ensure at most one attempt per browser session.
45
45
  *
46
46
  * The product view is no longer reported through a mutation here: the PDP now
47
47
  * exposes the product data as `<meta property="product:*">` tags, which the
@@ -49,9 +49,14 @@ export function resetStartRecommendationSessionLock() {
49
49
  *
50
50
  * All work runs client-side in effects (after hydration), so it doesn't affect
51
51
  * SSR/TTFB or Lighthouse render metrics.
52
+ *
53
+ * @param enabled Optional override. When omitted, uses
54
+ * `storeConfig.experimental.enableRecommendations`. When provided (e.g. by
55
+ * RecommendationShelf fallback), that boolean wins.
52
56
  */
53
- export function useStartRecommendationSession(pageProps?: unknown) {
54
- const enableRecommendations = hasEnabledRecommendationShelf(pageProps)
57
+ export function useStartRecommendationSession(enabled?: boolean) {
58
+ const flagEnabled = storeConfig.experimental?.enableRecommendations === true
59
+ const enableRecommendations = enabled ?? flagEnabled
55
60
 
56
61
  const [runStartRecommendationSession] = useLazyQuery<
57
62
  StartRecommendationSessionData,
@@ -1,6 +1,7 @@
1
1
  import { usePDP } from '@faststore/core'
2
2
  import { useMemo } from 'react'
3
3
 
4
+ import { useStartRecommendationSession } from 'src/sdk/analytics/hooks/useStartRecommendationSession'
4
5
  import { useCart } from 'src/sdk/cart'
5
6
 
6
7
  import type { ItemContext } from './types'
@@ -50,12 +51,6 @@ export function getRecommendationArguments(
50
51
 
51
52
  export type UseRecommendationShelfArgs = {
52
53
  readonly campaignVrn: string
53
- /**
54
- * Opt-in for VTEX Recommendations. When `false` (default) the campaign is
55
- * never requested. The anonymous user id is still resolved, but it goes
56
- * unused and never leaves the browser.
57
- */
58
- readonly enableRecommendations?: boolean
59
54
  /**
60
55
  * Where to read the products used as context for the recommendation request.
61
56
  * @default 'PDP'
@@ -79,14 +74,25 @@ const EMPTY_ITEMS: RecommendationResponse['products'] = []
79
74
  * campaign. It renders nothing — presentation lives in the shelf components
80
75
  * that consume it, so a new surface can reuse the campaign rules instead of
81
76
  * reimplementing them.
77
+ *
78
+ * When `userId` is missing (`undefined` or `null`), starts the personalization
79
+ * session as a fallback in parallel with cookie retry (Layout already starts
80
+ * it when `experimental.enableRecommendations` is on). Cookie + in-memory lock
81
+ * keep the mutation to one attempt per browser session.
82
82
  */
83
83
  export function useRecommendationShelf({
84
84
  campaignVrn,
85
- enableRecommendations = false,
86
85
  itemsContext = 'PDP',
87
86
  }: UseRecommendationShelfArgs): UseRecommendationShelfResult {
88
87
  const userId = useRecommendationUserId(campaignVrn)
89
88
 
89
+ // Fallback when Layout did not start the session (flag off): start in
90
+ // parallel with cookie lookup. `undefined` and `null` both mean no id yet;
91
+ // waiting for `null` exhausts the retry budget before the mutation can set
92
+ // `vtex-rec-user-id`, so the first page would never fetch. Cookie + lock
93
+ // already no-op when a session exists.
94
+ useStartRecommendationSession(!userId)
95
+
90
96
  const { data: productDetailPage } = usePDP()
91
97
  const { items: cartItems } = useCart()
92
98
 
@@ -108,14 +114,10 @@ export function useRecommendationShelf({
108
114
  return pdpProduct ? [pdpProduct] : []
109
115
  }, [itemsContext, cartItems, productDetailPage])
110
116
 
111
- // Gate the whole feature behind the CMS opt-in: when Recommendations is
112
- // disabled the shelf never requests recommendations (nor renders).
113
- const recommendationArgs = enableRecommendations
114
- ? getRecommendationArguments(campaignVrn, {
115
- userId,
116
- contextProducts,
117
- })
118
- : null
117
+ const recommendationArgs = getRecommendationArguments(campaignVrn, {
118
+ userId,
119
+ contextProducts,
120
+ })
119
121
 
120
122
  const { data, isLoading, error } = useRecommendations(recommendationArgs)
121
123
 
@@ -41,6 +41,10 @@ vi.mock('src/sdk/recommendations/useRecommendationUserId', () => ({
41
41
  useRecommendationUserId,
42
42
  }))
43
43
 
44
+ vi.mock('src/sdk/analytics/hooks/useStartRecommendationSession', () => ({
45
+ useStartRecommendationSession: vi.fn(),
46
+ }))
47
+
44
48
  import { RecommendationShelf } from 'src/components/sections/RecommendationShelf/RecommendationShelf'
45
49
 
46
50
  const CAMPAIGN_VRN = 'vrn:recommendations:acc:rec-top-items-v2:campaign-1'
@@ -71,7 +75,7 @@ afterEach(() => {
71
75
  })
72
76
 
73
77
  describe('RecommendationShelf', () => {
74
- it('renders a product card per recommended product when enabled', async () => {
78
+ it('renders a product card per recommended product', async () => {
75
79
  useRecommendations.mockReturnValue({
76
80
  data: recommendationData,
77
81
  isLoading: false,
@@ -79,7 +83,7 @@ describe('RecommendationShelf', () => {
79
83
  })
80
84
 
81
85
  const { getAllByTestId, getByText } = render(
82
- <RecommendationShelf campaignVrn={CAMPAIGN_VRN} enableRecommendations />
86
+ <RecommendationShelf campaignVrn={CAMPAIGN_VRN} />
83
87
  )
84
88
 
85
89
  await waitFor(() => {
@@ -98,9 +102,7 @@ describe('RecommendationShelf', () => {
98
102
  error: null,
99
103
  })
100
104
 
101
- render(
102
- <RecommendationShelf campaignVrn={CROSS_SELL_VRN} enableRecommendations />
103
- )
105
+ render(<RecommendationShelf campaignVrn={CROSS_SELL_VRN} />)
104
106
 
105
107
  await waitFor(() => {
106
108
  const lastArgs = useRecommendations.mock.calls.at(-1)?.[0]
@@ -120,11 +122,7 @@ describe('RecommendationShelf', () => {
120
122
  })
121
123
 
122
124
  const { getByText } = render(
123
- <RecommendationShelf
124
- campaignVrn={CAMPAIGN_VRN}
125
- enableRecommendations
126
- title="Custom title"
127
- />
125
+ <RecommendationShelf campaignVrn={CAMPAIGN_VRN} title="Custom title" />
128
126
  )
129
127
 
130
128
  await waitFor(() => {
@@ -140,25 +138,6 @@ describe('RecommendationShelf', () => {
140
138
  error: null,
141
139
  })
142
140
 
143
- const { container, queryByTestId } = render(
144
- <RecommendationShelf campaignVrn={CAMPAIGN_VRN} enableRecommendations />
145
- )
146
-
147
- await waitFor(() => {
148
- const lastArgs = useRecommendations.mock.calls.at(-1)?.[0]
149
- expect(lastArgs).toBeNull()
150
- })
151
- expect(queryByTestId('product-card')).toBeNull()
152
- expect(container).toBeEmptyDOMElement()
153
- })
154
-
155
- it('skips the fetch when recommendations are disabled (default)', async () => {
156
- useRecommendations.mockReturnValue({
157
- data: undefined,
158
- isLoading: false,
159
- error: null,
160
- })
161
-
162
141
  const { container, queryByTestId } = render(
163
142
  <RecommendationShelf campaignVrn={CAMPAIGN_VRN} />
164
143
  )
@@ -179,7 +158,7 @@ describe('RecommendationShelf', () => {
179
158
  })
180
159
 
181
160
  const { container } = render(
182
- <RecommendationShelf campaignVrn={CAMPAIGN_VRN} enableRecommendations />
161
+ <RecommendationShelf campaignVrn={CAMPAIGN_VRN} />
183
162
  )
184
163
 
185
164
  expect(container).toBeEmptyDOMElement()
@@ -193,7 +172,7 @@ describe('RecommendationShelf', () => {
193
172
  })
194
173
 
195
174
  const { container, queryByTestId } = render(
196
- <RecommendationShelf campaignVrn={CAMPAIGN_VRN} enableRecommendations />
175
+ <RecommendationShelf campaignVrn={CAMPAIGN_VRN} />
197
176
  )
198
177
 
199
178
  expect(queryByTestId('product-card')).toBeNull()
@@ -208,7 +187,7 @@ describe('RecommendationShelf', () => {
208
187
  })
209
188
 
210
189
  const { getByTestId } = render(
211
- <RecommendationShelf campaignVrn={CAMPAIGN_VRN} enableRecommendations />
190
+ <RecommendationShelf campaignVrn={CAMPAIGN_VRN} />
212
191
  )
213
192
 
214
193
  expect(getByTestId('skeleton').getAttribute('data-loading')).toBe('true')
@@ -18,50 +18,21 @@ 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
+
21
26
  import {
22
27
  resetStartRecommendationSessionLock,
23
28
  useStartRecommendationSession,
24
29
  } from 'src/sdk/analytics/hooks/useStartRecommendationSession'
25
30
 
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
- }
60
-
61
31
  beforeEach(() => {
62
32
  resetStartRecommendationSessionLock()
63
33
  useLazyQueryMock.mockReturnValue([runStartRecommendationSession, {}])
64
34
  runStartRecommendationSession.mockResolvedValue(true)
35
+ storeConfigMock.experimental.enableRecommendations = true
65
36
  })
66
37
 
67
38
  afterEach(() => {
@@ -69,18 +40,19 @@ afterEach(() => {
69
40
  })
70
41
 
71
42
  describe('useStartRecommendationSession', () => {
72
- it('does not start a session when no RecommendationShelf is enabled in CMS', async () => {
43
+ it('does not start a session when experimental.enableRecommendations is false', async () => {
44
+ storeConfigMock.experimental.enableRecommendations = false
73
45
  getCookie.mockReturnValue(undefined)
74
46
 
75
- renderHook(() => useStartRecommendationSession(disabledPageProps))
47
+ renderHook(() => useStartRecommendationSession())
76
48
 
77
49
  await waitFor(() => {
78
50
  expect(runStartRecommendationSession).not.toHaveBeenCalled()
79
51
  })
80
52
  })
81
53
 
82
- it('does not start a session when page props are missing', async () => {
83
- getCookie.mockReturnValue(undefined)
54
+ it('does not start a session when the session cookie already exists', async () => {
55
+ getCookie.mockReturnValue('already-started')
84
56
 
85
57
  renderHook(() => useStartRecommendationSession())
86
58
 
@@ -89,53 +61,50 @@ describe('useStartRecommendationSession', () => {
89
61
  })
90
62
  })
91
63
 
92
- it('does not start a session when the session cookie already exists', async () => {
93
- getCookie.mockReturnValue('already-started')
64
+ it('starts a session when the feature flag is on and no session cookie is present', async () => {
65
+ getCookie.mockReturnValue(undefined)
94
66
 
95
- renderHook(() => useStartRecommendationSession(enabledPageProps))
67
+ renderHook(() => useStartRecommendationSession())
96
68
 
97
69
  await waitFor(() => {
98
- expect(runStartRecommendationSession).not.toHaveBeenCalled()
70
+ expect(runStartRecommendationSession).toHaveBeenCalledTimes(1)
99
71
  })
100
72
  })
101
73
 
102
- it('starts a session when CMS enableRecommendations is true and no session cookie is present', async () => {
74
+ it('starts a session when enabled override is true even if the feature flag is off', async () => {
75
+ storeConfigMock.experimental.enableRecommendations = false
103
76
  getCookie.mockReturnValue(undefined)
104
77
 
105
- renderHook(() => useStartRecommendationSession(enabledPageProps))
78
+ renderHook(() => useStartRecommendationSession(true))
106
79
 
107
80
  await waitFor(() => {
108
81
  expect(runStartRecommendationSession).toHaveBeenCalledTimes(1)
109
82
  })
110
83
  })
111
84
 
112
- it('starts a session only once when the page has multiple enabled RecommendationShelves', async () => {
85
+ it('does not start a session when enabled override is false even if the feature flag is on', async () => {
113
86
  getCookie.mockReturnValue(undefined)
114
87
 
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 })
88
+ renderHook(() => useStartRecommendationSession(false))
123
89
 
124
90
  await waitFor(() => {
125
- expect(runStartRecommendationSession).toHaveBeenCalledTimes(1)
91
+ expect(runStartRecommendationSession).not.toHaveBeenCalled()
126
92
  })
127
93
  })
128
94
 
129
- it('does not start a second session when the hook mounts again without a cookie', async () => {
95
+ it('starts a session only once across remounts / re-renders (in-memory lock)', async () => {
130
96
  getCookie.mockReturnValue(undefined)
131
97
 
132
- renderHook(() => useStartRecommendationSession(enabledPageProps))
98
+ const { rerender } = renderHook(() => useStartRecommendationSession())
99
+
100
+ rerender()
101
+ rerender()
133
102
 
134
103
  await waitFor(() => {
135
104
  expect(runStartRecommendationSession).toHaveBeenCalledTimes(1)
136
105
  })
137
106
 
138
- renderHook(() => useStartRecommendationSession(enabledPageProps))
107
+ renderHook(() => useStartRecommendationSession())
139
108
 
140
109
  await waitFor(() => {
141
110
  expect(runStartRecommendationSession).toHaveBeenCalledTimes(1)
@@ -17,6 +17,11 @@ vi.mock('src/sdk/recommendations/useRecommendationUserId', () => ({
17
17
  useRecommendationUserId,
18
18
  }))
19
19
 
20
+ const useStartRecommendationSession = vi.hoisted(() => vi.fn())
21
+ vi.mock('src/sdk/analytics/hooks/useStartRecommendationSession', () => ({
22
+ useStartRecommendationSession,
23
+ }))
24
+
20
25
  import { useRecommendationShelf } from 'src/sdk/recommendations/useRecommendationShelf'
21
26
 
22
27
  const TOP_ITEMS_VRN = 'vrn:recommendations:acc:rec-top-items-v2:campaign-1'
@@ -53,12 +58,9 @@ afterEach(() => {
53
58
  })
54
59
 
55
60
  describe('useRecommendationShelf', () => {
56
- it('exposes the campaign payload when enabled', () => {
61
+ it('exposes the campaign payload', () => {
57
62
  const { result } = renderHook(() =>
58
- useRecommendationShelf({
59
- campaignVrn: TOP_ITEMS_VRN,
60
- enableRecommendations: true,
61
- })
63
+ useRecommendationShelf({ campaignVrn: TOP_ITEMS_VRN })
62
64
  )
63
65
 
64
66
  expect(result.current.items).toHaveLength(2)
@@ -66,32 +68,40 @@ describe('useRecommendationShelf', () => {
66
68
  expect(result.current.correlationId).toBe('corr-1')
67
69
  })
68
70
 
69
- it('never fetches while recommendations are disabled', () => {
71
+ it('never fetches before a userId resolves', () => {
72
+ useRecommendationUserId.mockReturnValue(null)
73
+
70
74
  renderHook(() => useRecommendationShelf({ campaignVrn: TOP_ITEMS_VRN }))
71
75
 
72
76
  expect(lastArgs()).toBeNull()
73
77
  })
74
78
 
75
- it('never fetches before a userId resolves', () => {
79
+ it('starts the recommendation session as a fallback when userId is missing', () => {
76
80
  useRecommendationUserId.mockReturnValue(null)
77
81
 
78
- renderHook(() =>
79
- useRecommendationShelf({
80
- campaignVrn: TOP_ITEMS_VRN,
81
- enableRecommendations: true,
82
- })
83
- )
82
+ renderHook(() => useRecommendationShelf({ campaignVrn: TOP_ITEMS_VRN }))
84
83
 
85
- expect(lastArgs()).toBeNull()
84
+ expect(useStartRecommendationSession).toHaveBeenCalledWith(true)
85
+ })
86
+
87
+ it('starts the recommendation session while userId is still resolving', () => {
88
+ useRecommendationUserId.mockReturnValue(undefined)
89
+
90
+ renderHook(() => useRecommendationShelf({ campaignVrn: TOP_ITEMS_VRN }))
91
+
92
+ expect(useStartRecommendationSession).toHaveBeenCalledWith(true)
93
+ })
94
+
95
+ it('does not start the recommendation session when userId already exists', () => {
96
+ useRecommendationUserId.mockReturnValue('user-1')
97
+
98
+ renderHook(() => useRecommendationShelf({ campaignVrn: TOP_ITEMS_VRN }))
99
+
100
+ expect(useStartRecommendationSession).toHaveBeenCalledWith(false)
86
101
  })
87
102
 
88
103
  it('never fetches on a malformed campaign vrn', () => {
89
- renderHook(() =>
90
- useRecommendationShelf({
91
- campaignVrn: 'not-a-vrn',
92
- enableRecommendations: true,
93
- })
94
- )
104
+ renderHook(() => useRecommendationShelf({ campaignVrn: 'not-a-vrn' }))
95
105
 
96
106
  expect(lastArgs()).toBeNull()
97
107
  })
@@ -104,7 +114,6 @@ describe('useRecommendationShelf', () => {
104
114
  renderHook(() =>
105
115
  useRecommendationShelf({
106
116
  campaignVrn: CROSS_SELL_VRN,
107
- enableRecommendations: true,
108
117
  itemsContext: 'CART',
109
118
  })
110
119
  )
@@ -120,7 +129,6 @@ describe('useRecommendationShelf', () => {
120
129
  renderHook(() =>
121
130
  useRecommendationShelf({
122
131
  campaignVrn: CROSS_SELL_VRN,
123
- enableRecommendations: true,
124
132
  itemsContext: 'CART',
125
133
  })
126
134
  )
@@ -132,7 +140,6 @@ describe('useRecommendationShelf', () => {
132
140
  renderHook(() =>
133
141
  useRecommendationShelf({
134
142
  campaignVrn: TOP_ITEMS_VRN,
135
- enableRecommendations: true,
136
143
  itemsContext: 'CART',
137
144
  })
138
145
  )
@@ -150,7 +157,6 @@ describe('useRecommendationShelf', () => {
150
157
  const { rerender } = renderHook(() =>
151
158
  useRecommendationShelf({
152
159
  campaignVrn: CROSS_SELL_VRN,
153
- enableRecommendations: true,
154
160
  itemsContext: 'CART',
155
161
  })
156
162
  )
@@ -166,12 +172,7 @@ describe('useRecommendationShelf', () => {
166
172
  data: { product: { isVariantOf: { productGroupID: 'pg-pdp' } } },
167
173
  })
168
174
 
169
- renderHook(() =>
170
- useRecommendationShelf({
171
- campaignVrn: CROSS_SELL_VRN,
172
- enableRecommendations: true,
173
- })
174
- )
175
+ renderHook(() => useRecommendationShelf({ campaignVrn: CROSS_SELL_VRN }))
175
176
 
176
177
  expect(lastArgs()?.products).toEqual(['pg-pdp'])
177
178
  })
@@ -185,10 +186,7 @@ describe('useRecommendationShelf', () => {
185
186
  })
186
187
 
187
188
  const { result } = renderHook(() =>
188
- useRecommendationShelf({
189
- campaignVrn: TOP_ITEMS_VRN,
190
- enableRecommendations: true,
191
- })
189
+ useRecommendationShelf({ campaignVrn: TOP_ITEMS_VRN })
192
190
  )
193
191
 
194
192
  expect(result.current.items).toEqual([])
@@ -0,0 +1,110 @@
1
+ import { renderHook, waitFor } from '@testing-library/react'
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3
+
4
+ const usePDP = vi.hoisted(() => vi.fn())
5
+ vi.mock('@faststore/core', () => ({ usePDP }))
6
+
7
+ vi.mock('@faststore/core/api', () => ({
8
+ gql: (s: string) => ({ __meta__: {}, query: s }),
9
+ }))
10
+
11
+ const useCart = vi.hoisted(() => vi.fn())
12
+ vi.mock('src/sdk/cart', () => ({ useCart }))
13
+
14
+ const useRecommendations = vi.hoisted(() => vi.fn())
15
+ vi.mock('src/sdk/recommendations/useRecommendations', () => ({
16
+ useRecommendations,
17
+ }))
18
+
19
+ const runStartRecommendationSession = vi.hoisted(() => vi.fn())
20
+ vi.mock('src/sdk/graphql/useLazyQuery', () => ({
21
+ useLazyQuery: () => [runStartRecommendationSession, {}],
22
+ }))
23
+
24
+ const cookies = vi.hoisted(() => ({
25
+ current: {} as Record<string, string | undefined>,
26
+ }))
27
+ vi.mock('src/utils/getCookie', () => ({
28
+ getCookie: (name: string) => cookies.current[name],
29
+ }))
30
+
31
+ const storeConfigMock = vi.hoisted(() => ({
32
+ experimental: { enableRecommendations: false },
33
+ }))
34
+ vi.mock('discovery.config', () => ({ default: storeConfigMock }))
35
+
36
+ // Keep retry semantics (loop until `until` / attempts) but skip wall-clock
37
+ // backoff so the cookie lookup can observe the session mutation in this test.
38
+ vi.mock('src/utils/retry', async (importOriginal) => {
39
+ const actual = await importOriginal<typeof import('src/utils/retry')>()
40
+ return {
41
+ retry: <T,>(
42
+ fn: () => T | Promise<T>,
43
+ options?: Parameters<typeof actual.retry>[1]
44
+ ) => actual.retry(fn, { ...options, delayMs: 0, maxDelayMs: 0 }),
45
+ }
46
+ })
47
+
48
+ import { resetStartRecommendationSessionLock } from 'src/sdk/analytics/hooks/useStartRecommendationSession'
49
+ import { useRecommendationShelf } from 'src/sdk/recommendations/useRecommendationShelf'
50
+
51
+ const TOP_ITEMS_VRN = 'vrn:recommendations:acc:rec-top-items-v2:campaign-1'
52
+
53
+ const lastArgs = () => useRecommendations.mock.calls.at(-1)?.[0]
54
+
55
+ beforeEach(() => {
56
+ cookies.current = {}
57
+ resetStartRecommendationSessionLock()
58
+ storeConfigMock.experimental.enableRecommendations = false
59
+ usePDP.mockReturnValue({ data: undefined })
60
+ useCart.mockReturnValue({ items: [] })
61
+ useRecommendations.mockReturnValue({
62
+ data: undefined,
63
+ isLoading: false,
64
+ error: null,
65
+ })
66
+ runStartRecommendationSession.mockImplementation(async () => {
67
+ cookies.current['vtex-rec-user-id'] = 'user-from-session'
68
+ cookies.current['vtex-rec-user-start-session'] = '1'
69
+ return true
70
+ })
71
+ })
72
+
73
+ afterEach(() => {
74
+ vi.clearAllMocks()
75
+ })
76
+
77
+ describe('useRecommendationShelf session fallback race', () => {
78
+ it('starts the session in parallel with cookie retry so the first page can fetch', async () => {
79
+ renderHook(() => useRecommendationShelf({ campaignVrn: TOP_ITEMS_VRN }))
80
+
81
+ await waitFor(() => {
82
+ expect(runStartRecommendationSession).toHaveBeenCalledTimes(1)
83
+ })
84
+
85
+ await waitFor(() => {
86
+ expect(lastArgs()).toEqual({
87
+ userId: 'user-from-session',
88
+ campaignVrn: TOP_ITEMS_VRN,
89
+ products: [],
90
+ })
91
+ })
92
+ })
93
+
94
+ it('does not start a session when the user id cookie is already present', async () => {
95
+ cookies.current['vtex-rec-user-id'] = 'existing-user'
96
+ cookies.current['vtex-rec-user-start-session'] = '1'
97
+
98
+ renderHook(() => useRecommendationShelf({ campaignVrn: TOP_ITEMS_VRN }))
99
+
100
+ await waitFor(() => {
101
+ expect(lastArgs()).toEqual({
102
+ userId: 'existing-user',
103
+ campaignVrn: TOP_ITEMS_VRN,
104
+ products: [],
105
+ })
106
+ })
107
+
108
+ expect(runStartRecommendationSession).not.toHaveBeenCalled()
109
+ })
110
+ })
@@ -1,49 +0,0 @@
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
- }