@faststore/core 4.7.0-dev.3 → 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.
- package/.turbo/turbo-generate.log +3 -3
- package/.turbo/turbo-test.log +89 -90
- package/CHANGELOG.md +12 -0
- package/cms/faststore/components/cms_component__recommendationshelf.jsonc +1 -7
- package/cms/faststore/schema.json +1 -7
- package/discovery.config.default.js +5 -0
- package/package.json +6 -6
- package/src/Layout.tsx +3 -3
- package/src/components/sections/RecommendationShelf/README.md +25 -15
- package/src/components/sections/RecommendationShelf/RecommendationShelf.tsx +0 -2
- package/src/components/sections/RecommendationShelf/RecommendationShelf.types.ts +0 -7
- package/src/sdk/analytics/hooks/useStartRecommendationSession.ts +18 -13
- package/src/sdk/recommendations/useRecommendationShelf.ts +17 -15
- package/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx +11 -32
- package/test/sdk/analytics/useStartRecommendationSession.test.ts +27 -58
- package/test/sdk/recommendations/useRecommendationShelf.browser.test.tsx +32 -34
- package/test/sdk/recommendations/useRecommendationShelf.sessionFallback.browser.test.tsx +110 -0
- package/src/sdk/analytics/utils/hasEnabledRecommendationShelf.ts +0 -49
- package/test/sdk/analytics/hasEnabledRecommendationShelf.test.ts +0 -88
|
@@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react'
|
|
|
2
2
|
|
|
3
3
|
import { gql } from '@faststore/core/api'
|
|
4
4
|
|
|
5
|
-
import
|
|
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
|
|
24
|
-
//
|
|
25
|
-
//
|
|
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`
|
|
38
|
-
* the
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
|
43
|
-
*
|
|
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(
|
|
54
|
-
const
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
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}
|
|
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}
|
|
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}
|
|
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}
|
|
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
|
|
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(
|
|
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
|
|
83
|
-
getCookie.mockReturnValue(
|
|
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('
|
|
93
|
-
getCookie.mockReturnValue(
|
|
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(
|
|
67
|
+
renderHook(() => useStartRecommendationSession())
|
|
96
68
|
|
|
97
69
|
await waitFor(() => {
|
|
98
|
-
expect(runStartRecommendationSession).
|
|
70
|
+
expect(runStartRecommendationSession).toHaveBeenCalledTimes(1)
|
|
99
71
|
})
|
|
100
72
|
})
|
|
101
73
|
|
|
102
|
-
it('starts a session when
|
|
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(
|
|
78
|
+
renderHook(() => useStartRecommendationSession(true))
|
|
106
79
|
|
|
107
80
|
await waitFor(() => {
|
|
108
81
|
expect(runStartRecommendationSession).toHaveBeenCalledTimes(1)
|
|
109
82
|
})
|
|
110
83
|
})
|
|
111
84
|
|
|
112
|
-
it('
|
|
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
|
-
|
|
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).
|
|
91
|
+
expect(runStartRecommendationSession).not.toHaveBeenCalled()
|
|
126
92
|
})
|
|
127
93
|
})
|
|
128
94
|
|
|
129
|
-
it('
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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('
|
|
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(
|
|
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
|
-
}
|