@faststore/core 4.7.0-dev.3 → 4.7.0-dev.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/.turbo/turbo-generate.log +3 -3
  2. package/.turbo/turbo-test.log +101 -92
  3. package/CHANGELOG.md +18 -0
  4. package/cms/faststore/components/cms_component__recommendationshelf.jsonc +1 -7
  5. package/cms/faststore/my-account/README.md +2 -2
  6. package/cms/faststore/schema.json +1 -7
  7. package/discovery.config.default.js +5 -0
  8. package/package.json +6 -6
  9. package/src/Layout.tsx +3 -3
  10. package/src/components/account/orders/OrderDetails/section.module.scss +44 -3
  11. package/src/components/cms/RenderSections.tsx +20 -2
  12. package/src/components/sections/RecommendationShelf/README.md +25 -15
  13. package/src/components/sections/RecommendationShelf/RecommendationShelf.tsx +0 -2
  14. package/src/components/sections/RecommendationShelf/RecommendationShelf.types.ts +0 -7
  15. package/src/pages/pvt/account/403.tsx +1 -0
  16. package/src/pages/pvt/account/404.tsx +5 -1
  17. package/src/pages/pvt/account/cards/index.tsx +1 -0
  18. package/src/pages/pvt/account/orders/[id].tsx +5 -1
  19. package/src/pages/pvt/account/orders/index.tsx +1 -0
  20. package/src/pages/pvt/account/profile.tsx +1 -0
  21. package/src/pages/pvt/account/security.tsx +1 -0
  22. package/src/pages/pvt/account/user-details.tsx +1 -0
  23. package/src/sdk/analytics/hooks/useStartRecommendationSession.ts +18 -13
  24. package/src/sdk/recommendations/useRecommendationShelf.ts +17 -15
  25. package/src/server/cms/myAccountDefaultSections.ts +8 -3
  26. package/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx +11 -32
  27. package/test/components/cms/RenderSections.browser.test.tsx +135 -0
  28. package/test/sdk/analytics/useStartRecommendationSession.test.ts +27 -58
  29. package/test/sdk/recommendations/useRecommendationShelf.browser.test.tsx +32 -34
  30. package/test/sdk/recommendations/useRecommendationShelf.sessionFallback.browser.test.tsx +110 -0
  31. package/test/server/cms/myAccountDefaultSections.test.ts +44 -0
  32. package/src/sdk/analytics/utils/hasEnabledRecommendationShelf.ts +0 -49
  33. package/test/sdk/analytics/hasEnabledRecommendationShelf.test.ts +0 -88
@@ -4,7 +4,7 @@ CMS section that renders a carousel of personalized product recommendations from
4
4
 
5
5
  - **Data source:** VTEX Recommendations BFF, exposed by `@faststore/api` through the `recommendations` GraphQL query.
6
6
  - **Registration:** global CMS component (`$componentKey: "RecommendationShelf"`), available on any page via CMS.
7
- - **Opt-in via CMS:** enable Recommendations with the **Enable recommendations?** toggle on the shelf (default `false`). No store code changes or feature flags are required.
7
+ - **Session opt-in:** personalization session starts via the `experimental.enableRecommendations` discovery feature flag (Layout, every page). When the flag is off, any mounted Recommendation Shelf starts the session as a fallback if `userId` is missing.
8
8
 
9
9
  ## Contents
10
10
 
@@ -22,7 +22,7 @@ CMS section that renders a carousel of personalized product recommendations from
22
22
 
23
23
  The shelf renders products only when the following conditions are met:
24
24
 
25
- 1. **Enable recommendations on the shelf.** Set **Enable recommendations?** to `true` on the Recommendation Shelf in the CMS. This opts the store into the personalization session (`startRecommendationSession` mutation, owned by Layout) and allows the shelf to fetch recommendations. Without it (default `false`), no session is started and the shelf does not fetch.
25
+ 1. **Personalization session.** Prefer enabling `experimental.enableRecommendations` in `discovery.config` so `Layout` starts the session on every page. If the flag is off, any mounted Recommendation Shelf starts the session as a fallback when `userId` is missing (cookie + in-memory lock still limit this to one attempt per browser session).
26
26
 
27
27
  2. **Active VTEX Recommendations campaign.** Provide a valid campaign **VRN**. Example: `vrn:recommendations:my-account:rec-persona-v2:abc123`.
28
28
 
@@ -32,7 +32,19 @@ The shelf renders products only when the following conditions are met:
32
32
 
33
33
  ## Setup
34
34
 
35
- ### 1. Obtain the campaign VRN
35
+ ### 1. Opt into the personalization session (recommended)
36
+
37
+ In the store's `discovery.config.js` (or local override), set:
38
+
39
+ ```js
40
+ experimental: {
41
+ enableRecommendations: true,
42
+ }
43
+ ```
44
+
45
+ Default is `false`. With the flag on, `Layout` calls `startRecommendationSession` on every page without inspecting CMS data. With the flag off, placing a Recommendation Shelf on a page still starts the session when `userId` is missing.
46
+
47
+ ### 2. Obtain the campaign VRN
36
48
 
37
49
  In the VTEX Recommendations admin, create or select a campaign and copy its **VRN**. The expected format is:
38
50
 
@@ -42,9 +54,9 @@ vrn:recommendations:<account>:<campaign-type>:<campaign-id>
42
54
 
43
55
  Supported `<campaign-type>` values are listed under [Campaign types (VRN) and context](#campaign-types-vrn-and-context).
44
56
 
45
- ### 2. Add Recommendation Shelf via CMS
57
+ ### 3. Add Recommendation Shelf via CMS
46
58
 
47
- Add **Recommendation Shelf** to the target page (home, PDP, PLP, cart, and so on), configure at least **Campaign VRN** (required), and turn **Enable recommendations?** on.
59
+ Add **Recommendation Shelf** to the target page (home, PDP, PLP, cart, and so on) and configure at least **Campaign VRN** (required).
48
60
 
49
61
  ## CMS configuration
50
62
 
@@ -52,7 +64,6 @@ Schema: `cms/faststore/components/cms_component__recommendationshelf.jsonc`.
52
64
 
53
65
  | CMS property | Key | Type | Required | Default | Description |
54
66
  | --- | --- | --- | --- | --- | --- |
55
- | Enable recommendations? | `enableRecommendations` | boolean | No | `false` | Opt-in that starts the personalization session (via Layout) and allows this shelf to fetch recommendations. |
56
67
  | Title | `title` | string | No | — | Overrides the shelf title. Falls back to the campaign title. If both are empty, the heading is not rendered. |
57
68
  | Campaign VRN | `campaignVrn` | string | **Yes** | — | Recommendation campaign VRN. Validated by regex (see campaign types below). |
58
69
  | Items context | `itemsContext` | `PDP` \| `CART` | No | `PDP` | Source of products used as request context. Applies only to context-based campaigns. |
@@ -92,9 +103,9 @@ Context-based campaigns (cross-sell, similar items, visual similarity, next inte
92
103
 
93
104
  All recommendation work runs client-side after hydration:
94
105
 
95
- 1. **Session start (CMS opt-in)** — `Layout` calls `useStartRecommendationSession(pageProps)` once. The hook checks whether any `RecommendationShelf` in the CMS data has `enableRecommendations: true` and no-ops when none do. Multiple enabled shelves on the same page still start the session only once (in-memory lock + session cookie).
106
+ 1. **Session start (feature flag + shelf fallback)** — `Layout` calls `useStartRecommendationSession()` once. The hook starts the session when `experimental.enableRecommendations` is `true` (no CMS lookup). When the flag is `false`, `useRecommendationShelf()` (used by every RecommendationShelf) calls the same hook as a fallback while `userId` is missing (`undefined` or `null`), in parallel with cookie retry. Cookie (`vtex-rec-user-start-session`) + in-memory lock ensure at most one attempt per browser session. If `userId` is already available, the shelf does not start the session.
96
107
  2. **User id resolution** — `useRecommendationUserId()` reads the `vtex-rec-user-id` cookie, retrying until a value is available or the retry budget is exhausted.
97
- 3. **Request arguments** — when enabled, `getRecommendationArguments()` builds `{ userId, campaignVrn, products }` or returns `null` when the VRN is invalid, the user id is missing, or a context-based campaign has no context products. When disabled, arguments stay `null`.
108
+ 3. **Request arguments** — `getRecommendationArguments()` builds `{ userId, campaignVrn, products }` or returns `null` when the VRN is invalid, the user id is missing, or a context-based campaign has no context products.
98
109
  4. **Fetch** — `useRecommendations()` runs `ClientRecommendationsQuery` and returns `products`, `correlationId`, and `campaign`.
99
110
  5. **Render** — shows `ProductShelfSkeleton` while loading; returns `null` on error or empty results; otherwise renders the carousel and optional heading.
100
111
  6. **Tracking** — when correlation and campaign identifiers are present, the shelf emits `data-af-*` attributes for Activity Flow. PDP product views use `product:*` meta tags from `pages/[slug]/p.tsx`.
@@ -107,6 +118,7 @@ Related files:
107
118
  - Shelf: `src/components/sections/RecommendationShelf/`
108
119
  - Hook: `src/sdk/analytics/hooks/useStartRecommendationSession.ts`
109
120
  - Layout: `src/Layout.tsx`
121
+ - Flag: `discovery.config.default.js` → `experimental.enableRecommendations`
110
122
 
111
123
  ## Code-level overrides
112
124
 
@@ -116,7 +128,6 @@ In addition to CMS props, the component accepts code-level overrides (not expose
116
128
  import { RecommendationShelf } from 'src/components/sections/RecommendationShelf'
117
129
 
118
130
  <RecommendationShelf
119
- enableRecommendations
120
131
  campaignVrn="vrn:recommendations:my-account:rec-cross-v2:abc123"
121
132
  itemsContext="PDP"
122
133
  ProductCard={MyCustomCard}
@@ -143,7 +154,7 @@ As a global component, the shelf can be placed on any CMS page. Suggested placem
143
154
  - **Cart:** cross-sell with `itemsContext: CART`.
144
155
  - **PLP:** context-agnostic campaigns.
145
156
 
146
- The personalization session runs only when at least one shelf has **Enable recommendations?** turned on.
157
+ Prefer enabling `experimental.enableRecommendations` so the session is ready on every page. Stores without the flag and without a Recommendation Shelf never call `startRecommendationSession`.
147
158
 
148
159
  ## Privacy and cache
149
160
 
@@ -155,11 +166,10 @@ The personalization session runs only when at least one shelf has **Enable recom
155
166
 
156
167
  **The shelf does not render.** Check, in order:
157
168
 
158
- 1. Is **Enable recommendations?** set to `true` on the shelf?
159
- 2. Is `campaignVrn` valid and is the campaign active?
160
- 3. Is the `vtex-rec-user-id` cookie available? Without a user id, the shelf does not fetch.
161
- 4. For context-based campaigns, is product context available (`PDP` on a product page, or a non-empty cart for `CART`)?
162
- 5. Did the campaign return products? An empty response renders nothing by design.
169
+ 1. Is `campaignVrn` valid and is the campaign active?
170
+ 2. Is the `vtex-rec-user-id` cookie available? Without a user id, the shelf does not fetch. With the discovery flag off, confirm the shelf fallback started the session (`vtex-rec-user-start-session`).
171
+ 3. For context-based campaigns, is product context available (`PDP` on a product page, or a non-empty cart for `CART`)?
172
+ 4. Did the campaign return products? An empty response renders nothing by design.
163
173
 
164
174
  **Skeleton never resolves.** Inspect the console and the GraphQL `recommendations` response for pending or failing requests.
165
175
 
@@ -21,7 +21,6 @@ export function RecommendationShelf<
21
21
  >({
22
22
  title,
23
23
  campaignVrn,
24
- enableRecommendations = false,
25
24
  itemsContext = 'PDP',
26
25
  ProductCard,
27
26
  mapProductToProductCard,
@@ -56,7 +55,6 @@ export function RecommendationShelf<
56
55
  const { items, isLoading, error, campaign, correlationId } =
57
56
  useRecommendationShelf({
58
57
  campaignVrn,
59
- enableRecommendations,
60
58
  itemsContext,
61
59
  })
62
60
 
@@ -25,13 +25,6 @@ export type RecommendationShelfProps<
25
25
  > = {
26
26
  readonly title?: string
27
27
  readonly campaignVrn: string
28
- /**
29
- * Opt-in for VTEX Recommendations on this shelf. When `true`, Layout starts
30
- * the personalization session (`useStartRecommendationSession`) and this
31
- * shelf fetches recommendations. When `false` (default), neither happens.
32
- * @default false
33
- */
34
- readonly enableRecommendations?: boolean
35
28
  /**
36
29
  * Where to read the products used as context for the recommendation request:
37
30
  * - `'PDP'`: the current product detail page product.
@@ -66,6 +66,7 @@ function Page({
66
66
  <NextSeo noindex nofollow />
67
67
 
68
68
  <RenderSectionsBase
69
+ skipLazyLoading
69
70
  sections={pageSections}
70
71
  components={ACCOUNT_COMPONENTS}
71
72
  />
@@ -57,7 +57,11 @@ function Page({
57
57
 
58
58
  <Layout accountName={accountName}>
59
59
  {sections && sections.length > 0 && (
60
- <RenderSectionsBase sections={sections} components={COMPONENTS} />
60
+ <RenderSectionsBase
61
+ skipLazyLoading
62
+ sections={sections}
63
+ components={COMPONENTS}
64
+ />
61
65
  )}
62
66
  </Layout>
63
67
  </RenderSections>
@@ -94,6 +94,7 @@ export default function ListCardsPage({
94
94
  navigationLabels={navigationLabels}
95
95
  >
96
96
  <RenderSectionsBase
97
+ skipLazyLoading
97
98
  sections={pageSections}
98
99
  components={ACCOUNT_COMPONENTS}
99
100
  />
@@ -76,6 +76,7 @@ export default function OrderDetailsPage({
76
76
  <OrderDetailsPageShell>
77
77
  <main data-fs-order-details-content>
78
78
  <RenderSectionsBase
79
+ skipLazyLoading
79
80
  sections={pageSections}
80
81
  components={ACCOUNT_COMPONENTS}
81
82
  />
@@ -408,7 +409,10 @@ const getServerSidePropsBase: GetServerSideProps<
408
409
  navigationLabels: navigationData as AccountNavigationLabels,
409
410
  accountPageData: {
410
411
  order: orderDetails.data.userOrder,
411
- orderStatusLabels,
412
+ // Yields undefined when no section carries order-status labels — the
413
+ // case for a store with no CMS content, since the default sections are
414
+ // created with empty data. getServerSideProps cannot serialize it.
415
+ ...(orderStatusLabels ? { orderStatusLabels } : {}),
412
416
  },
413
417
  pageSections,
414
418
  isRepresentative,
@@ -75,6 +75,7 @@ export default function ListOrdersPage({
75
75
  >
76
76
  <BeforeSection />
77
77
  <RenderSectionsBase
78
+ skipLazyLoading
78
79
  sections={pageSections}
79
80
  components={ACCOUNT_COMPONENTS}
80
81
  />
@@ -74,6 +74,7 @@ export default function Profile({
74
74
  >
75
75
  <BeforeSection />
76
76
  <RenderSectionsBase
77
+ skipLazyLoading
77
78
  sections={pageSections}
78
79
  components={ACCOUNT_COMPONENTS}
79
80
  />
@@ -74,6 +74,7 @@ export default function Page({
74
74
  >
75
75
  <BeforeSection />
76
76
  <RenderSectionsBase
77
+ skipLazyLoading
77
78
  sections={pageSections}
78
79
  components={ACCOUNT_COMPONENTS}
79
80
  />
@@ -73,6 +73,7 @@ export default function Page({
73
73
  >
74
74
  <BeforeSection />
75
75
  <RenderSectionsBase
76
+ skipLazyLoading
76
77
  sections={pageSections}
77
78
  components={ACCOUNT_COMPONENTS}
78
79
  />
@@ -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
 
@@ -20,16 +20,21 @@ const DEFAULT_SECTION_KEYS: Record<NativeMyAccountContentType, string[]> = {
20
20
  myAccountProfile: ['AccountNavigation', 'AccountProfile'],
21
21
  myAccountOrders: ['AccountNavigation', 'AccountOrdersList'],
22
22
  myAccountCards: ['AccountNavigation', 'AccountListCards'],
23
+ // Mirrors the render order of the `@deprecated OrderDetails.tsx` component,
24
+ // which is the design reference available in the repo. The delivery option
25
+ // accordions render inside AccountOrderDelivery, so they sit right after the
26
+ // compact delivery card instead of after the summary. Known deviation: fixing
27
+ // it would mean splitting AccountOrderDelivery into two CMS sections.
23
28
  myAccountOrderDetails: [
24
29
  'AccountNavigation',
25
30
  'AccountOrderDetails',
31
+ 'AccountOrderOrderedBy',
32
+ 'AccountOrderDelivery',
26
33
  'AccountOrderStatus',
27
34
  'AccountOrderPayment',
28
- 'AccountOrderDelivery',
29
35
  'AccountOrderSummary',
30
- 'AccountOrderOrderedBy',
31
- 'AccountOrderBudgets',
32
36
  'AccountOrderMoreInfo',
37
+ 'AccountOrderBudgets',
33
38
  ],
34
39
  myAccountUserDetails: ['AccountNavigation', 'AccountUserDetails'],
35
40
  myAccountSecurity: ['AccountNavigation', 'AccountSecurity'],
@@ -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')
@@ -0,0 +1,135 @@
1
+ import { UIProvider } from '@faststore/ui'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { renderToString } from 'react-dom/server'
4
+ import { beforeAll, describe, expect, it } from 'vitest'
5
+
6
+ import { RenderSectionsBase } from 'src/components/cms/RenderSections'
7
+ import { getDefaultMyAccountSections } from 'src/server/cms/myAccountDefaultSections'
8
+
9
+ /**
10
+ * First-paint behavior of the My Account pages.
11
+ *
12
+ * Sections rendered through `RenderSectionsBase` go through `ViewportObserver`,
13
+ * which emits an 823px-tall placeholder instead of the section content until the
14
+ * element intersects the viewport. That is correct for the public storefront and
15
+ * wrong for My Account, which is authenticated, SSR and above the fold.
16
+ *
17
+ * `skipLazyLoading` opts a whole render pass out of it.
18
+ */
19
+
20
+ /** Never-firing observer — reproduces the pre-intersection (first paint) state. */
21
+ class NeverIntersectingObserver implements IntersectionObserver {
22
+ readonly root = null
23
+ readonly rootMargin = ''
24
+ readonly thresholds = [] as unknown as ReadonlyArray<number>
25
+ observe() {}
26
+ unobserve() {}
27
+ disconnect() {}
28
+ takeRecords() {
29
+ return []
30
+ }
31
+ }
32
+
33
+ beforeAll(() => {
34
+ window.IntersectionObserver =
35
+ NeverIntersectingObserver as unknown as typeof IntersectionObserver
36
+ })
37
+
38
+ const orderDetailsSections = getDefaultMyAccountSections(
39
+ 'myAccountOrderDetails'
40
+ ).filter((section) => section.$componentKey !== 'AccountNavigation')
41
+
42
+ const markerComponents = Object.fromEntries(
43
+ orderDetailsSections.map((section) => [
44
+ section.$componentKey,
45
+ () => <div>{section.$componentKey}</div>,
46
+ ])
47
+ )
48
+
49
+ describe('skipLazyLoading', () => {
50
+ it('renders section content immediately instead of a placeholder', () => {
51
+ const { container } = render(
52
+ <UIProvider>
53
+ <RenderSectionsBase
54
+ skipLazyLoading
55
+ sections={[{ name: 'AccountOrderStatus', data: {} }]}
56
+ components={{ AccountOrderStatus: () => <div>status</div> }}
57
+ />
58
+ </UIProvider>
59
+ )
60
+
61
+ expect(screen.getByText('status')).toBeTruthy()
62
+ expect(container.querySelector('[data-store-section-name]')).toBeNull()
63
+ })
64
+
65
+ it('renders every default order-details section on first paint', () => {
66
+ const { container } = render(
67
+ <UIProvider>
68
+ <RenderSectionsBase
69
+ skipLazyLoading
70
+ sections={orderDetailsSections}
71
+ components={markerComponents}
72
+ />
73
+ </UIProvider>
74
+ )
75
+
76
+ for (const section of orderDetailsSections) {
77
+ expect(screen.getByText(section.$componentKey)).toBeTruthy()
78
+ }
79
+
80
+ expect(
81
+ container.querySelectorAll('[data-store-section-name]')
82
+ ).toHaveLength(0)
83
+ })
84
+
85
+ it('covers store-authored sections, not only the framework defaults', () => {
86
+ render(
87
+ <UIProvider>
88
+ <RenderSectionsBase
89
+ skipLazyLoading
90
+ sections={[{ name: 'StoreCustomAccountSection', data: {} }]}
91
+ components={{
92
+ StoreCustomAccountSection: () => <div>custom</div>,
93
+ }}
94
+ />
95
+ </UIProvider>
96
+ )
97
+
98
+ expect(screen.getByText('custom')).toBeTruthy()
99
+ })
100
+
101
+ it('puts the section content in the SSR HTML, not the placeholder', () => {
102
+ const html = renderToString(
103
+ <UIProvider>
104
+ <RenderSectionsBase
105
+ skipLazyLoading
106
+ sections={orderDetailsSections}
107
+ components={markerComponents}
108
+ />
109
+ </UIProvider>
110
+ )
111
+
112
+ expect(html).toContain('AccountOrderStatus')
113
+ expect(html).not.toContain('data-store-section-name')
114
+ expect(html).not.toContain('823')
115
+ })
116
+
117
+ it('preserves lazy loading when the flag is absent', () => {
118
+ const { container } = render(
119
+ <UIProvider>
120
+ <RenderSectionsBase
121
+ sections={[{ name: 'AccountOrderStatus', data: {} }]}
122
+ components={{ AccountOrderStatus: () => <div>status</div> }}
123
+ />
124
+ </UIProvider>
125
+ )
126
+
127
+ expect(screen.queryByText('status')).toBeNull()
128
+
129
+ const placeholder = container.querySelector(
130
+ '[data-store-section-name="AccountOrderStatus"]'
131
+ ) as HTMLElement | null
132
+
133
+ expect(placeholder?.style.height).toBe('823px')
134
+ })
135
+ })