@faststore/core 3.100.7 → 3.100.8

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 (46) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/build-manifest.json +23 -23
  3. package/.next/cache/.tsbuildinfo +1 -1
  4. package/.next/cache/config.json +3 -3
  5. package/.next/cache/webpack/client-production/0.pack +2 -2
  6. package/.next/cache/webpack/client-production/index.pack +0 -0
  7. package/.next/cache/webpack/edge-server-production/0.pack +0 -0
  8. package/.next/cache/webpack/edge-server-production/index.pack +0 -0
  9. package/.next/cache/webpack/server-production/0.pack +2 -2
  10. package/.next/cache/webpack/server-production/index.pack +0 -0
  11. package/.next/prerender-manifest.js +1 -1
  12. package/.next/prerender-manifest.json +1 -1
  13. package/.next/react-loadable-manifest.json +2 -2
  14. package/.next/routes-manifest.json +1 -1
  15. package/.next/server/chunks/948.js +1 -1
  16. package/.next/server/functions-config-manifest.json +1 -1
  17. package/.next/server/middleware-build-manifest.js +1 -1
  18. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  19. package/.next/server/pages/api/preview.js +1 -1
  20. package/.next/server/pages/en-US/404.html +2 -2
  21. package/.next/server/pages/en-US/500.html +2 -2
  22. package/.next/server/pages/en-US/checkout.html +2 -2
  23. package/.next/server/pages/en-US/login.html +2 -2
  24. package/.next/server/pages/en-US/password-protection.html +2 -2
  25. package/.next/server/pages/en-US/s.html +2 -2
  26. package/.next/server/pages/en-US.html +2 -2
  27. package/.next/server/pages-manifest.json +1 -1
  28. package/.next/static/{Oxei21wY_TrPYtPGUrFiR → 3we-qu8I2EmorLqlDX8oP}/_buildManifest.js +1 -1
  29. package/.next/static/chunks/7783.017603f0dd9e1dd7.js +1 -0
  30. package/.next/static/chunks/pages/{[...slug]-cf9e3bf4febd21c3.js → [...slug]-d2b8abf5b0c0bb19.js} +1 -1
  31. package/.next/static/chunks/{webpack-bde714c56fd09a0c.js → webpack-62bcb2a452e15476.js} +1 -1
  32. package/.next/trace +156 -156
  33. package/.turbo/turbo-build.log +10 -10
  34. package/.turbo/turbo-test.log +11 -10
  35. package/CHANGELOG.md +9 -0
  36. package/package.json +2 -2
  37. package/src/server/cms/index.ts +59 -7
  38. package/src/server/cms/pdp.ts +10 -5
  39. package/src/server/cms/plp.ts +10 -5
  40. package/src/server/content/promiseCache.ts +68 -0
  41. package/src/server/content/service.ts +56 -38
  42. package/test/server/cms/index.test.ts +112 -8
  43. package/test/server/content/promiseCache.test.ts +150 -0
  44. package/test/server/content/service.test.ts +229 -5
  45. package/.next/static/chunks/3802.9744a010fe6a4d07.js +0 -1
  46. /package/.next/static/{Oxei21wY_TrPYtPGUrFiR → 3we-qu8I2EmorLqlDX8oP}/_ssgManifest.js +0 -0
@@ -1,23 +1,23 @@
1
1
 
2
- > @faststore/core@3.100.6 prebuild /home/runner/work/faststore/faststore/packages/core
2
+ > @faststore/core@3.100.7 prebuild /home/runner/work/faststore/faststore/packages/core
3
3
  > na run partytown && na run generate
4
4
 
5
5
 
6
- > @faststore/core@3.100.6 partytown /home/runner/work/faststore/faststore/packages/core
6
+ > @faststore/core@3.100.7 partytown /home/runner/work/faststore/faststore/packages/core
7
7
  > partytown copylib ./public/~partytown
8
8
 
9
9
  Partytown lib copied to: /home/runner/work/faststore/faststore/packages/core/public/~partytown
10
10
 
11
- > @faststore/core@3.100.6 generate /home/runner/work/faststore/faststore/packages/core
11
+ > @faststore/core@3.100.7 generate /home/runner/work/faststore/faststore/packages/core
12
12
  > na run generate:schema && na run generate:codegen && na run format:generated
13
13
 
14
14
 
15
- > @faststore/core@3.100.6 generate:schema /home/runner/work/faststore/faststore/packages/core
15
+ > @faststore/core@3.100.7 generate:schema /home/runner/work/faststore/faststore/packages/core
16
16
  > tsx src/server/generator/generateGraphQLSchemaFile.ts
17
17
 
18
18
  Schema GraphQL file generated successfully
19
19
 
20
- > @faststore/core@3.100.6 generate:codegen /home/runner/work/faststore/faststore/packages/core
20
+ > @faststore/core@3.100.7 generate:codegen /home/runner/work/faststore/faststore/packages/core
21
21
  > graphql-codegen
22
22
 
23
23
  [STARTED] Parse Configuration
@@ -37,11 +37,11 @@ Running lifecycle hook "afterStart" scripts...
37
37
  [CLI] Loading Documents
38
38
  [CLI] Generating output
39
39
 
40
- > @faststore/core@3.100.6 format:generated /home/runner/work/faststore/faststore/packages/core
40
+ > @faststore/core@3.100.7 format:generated /home/runner/work/faststore/faststore/packages/core
41
41
  > prettier --write "@generated/**/*.{ts,js,tsx,jsx,json}" --loglevel error
42
42
 
43
43
 
44
- > @faststore/core@3.100.6 build /home/runner/work/faststore/faststore/packages/core
44
+ > @faststore/core@3.100.7 build /home/runner/work/faststore/faststore/packages/core
45
45
  > next build
46
46
 
47
47
  ⚠ No build cache found. Please configure build caching for faster rebuilds. Read more: https://nextjs.org/docs/messages/no-cache
@@ -101,10 +101,10 @@ Import trace for requested module:
101
101
  Generating static pages (0/8) ...
102
102
 
103
103
  Generating static pages (2/8)
104
104
  Search not found. Add a new component for this section or remove it from the CMS
105
- Search not found. Add a new component for this section or remove it from the CMS
106
105
  Warning: Dynamic Content not found for the page: home. Refer to the Dynamic Content documentation at https://developers.vtex.com/docs/guides/faststore/dynamic-content-overview for mapping the page and the corresponding data-fetching function.
107
-
108
106
  Generating static pages (4/8)
109
107
  Search not found. Add a new component for this section or remove it from the CMS
108
+ Search not found. Add a new component for this section or remove it from the CMS
109
+
110
110
  Generating static pages (4/8)
111
111
  Search not found. Add a new component for this section or remove it from the CMS
112
112
 
113
113
  Generating static pages (6/8)
114
114
  Search not found. Add a new component for this section or remove it from the CMS
@@ -154,7 +154,7 @@ Route (pages) Size First Load JS
154
154
  ├ chunks/framework-d514426edf885c68.js 45.4 kB
155
155
  ├ chunks/main-a2d1cfe626f2fef1.js 33.4 kB
156
156
  ├ chunks/pages/_app-35e8dda638ee2672.js 26.6 kB
157
- ├ chunks/webpack-bde714c56fd09a0c.js 3.9 kB
157
+ ├ chunks/webpack-62bcb2a452e15476.js 3.9 kB
158
158
  └ css/146a8678babc61bf.css 9.2 kB
159
159
 
160
160
  ƒ Middleware 44.8 kB
@@ -1,23 +1,24 @@
1
1
 
2
- > @faststore/core@3.100.6 test /home/runner/work/faststore/faststore/packages/core
2
+ > @faststore/core@3.100.7 test /home/runner/work/faststore/faststore/packages/core
3
3
  > jest
4
4
 
5
- PASS test/server/content/service.test.ts (15.95 s)
6
- PASS test/utils/cookieCacheBusting.test.ts (15.164 s)
7
- PASS test/server/password-protection-service.test.ts (17.702 s)
5
+ PASS test/server/content/service.test.ts (13.432 s)
6
+ PASS test/utils/cookieCacheBusting.test.ts (11.728 s)
7
+ PASS test/server/password-protection-service.test.ts (13.8 s)
8
8
  PASS test/utils/multipleTemplates.test.ts
9
9
  PASS test/utils/clearCookies.test.ts
10
+ PASS test/server/cms/index.test.ts
10
11
  PASS test/sdk/redirects/index.test.ts
11
12
  PASS test/utils/trustedForwardedHost.test.ts
12
- PASS test/utils/validateSessionRefreshToken.test.ts
13
+ PASS test/server/content/promiseCache.test.ts
13
14
  PASS test/server/cms/global.test.ts
15
+ PASS test/utils/validateSessionRefreshToken.test.ts
14
16
  PASS test/utils/isLocalHost.test.ts
15
17
  PASS test/utils/isLocalHost.browser.test.ts
16
- PASS test/server/cms/index.test.ts
17
- PASS test/server/index.test.ts (9.929 s)
18
+ PASS test/server/index.test.ts (5.995 s)
18
19
 
19
- Test Suites: 13 passed, 13 total
20
- Tests: 128 passed, 128 total
20
+ Test Suites: 14 passed, 14 total
21
+ Tests: 155 passed, 155 total
21
22
  Snapshots: 0 total
22
- Time: 28.81 s
23
+ Time: 21.421 s
23
24
  Ran all test suites.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,15 @@
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
+ ## [3.100.8](https://github.com/vtex/faststore/compare/v3.100.7...v3.100.8) (2026-10-27)
7
+
8
+ ### Bug Fixes
9
+
10
+ - **core:** address review feedback on CMS cache (3.x) ([ef77b3f](https://github.com/vtex/faststore/commit/ef77b3fa73d2cd475fef6129a3d0a948b9b59cbc))
11
+ - **core:** cache Content Platform template list across page generations (3.x) ([6480dd5](https://github.com/vtex/faststore/commit/6480dd5850baaacd3e9cc698be4690795b63ab7a)), closes [#3526](https://github.com/vtex/faststore/issues/3526)
12
+ - **core:** cache global and single CMS content across page generations (3.x) ([01d1b3a](https://github.com/vtex/faststore/commit/01d1b3a6ab6df30676f38275399ba6e5d3e0213b))
13
+ - **core:** do not cache empty required CMS content (3.x) ([df849cb](https://github.com/vtex/faststore/commit/df849cb3b967b305fa67b2cc7b3f7fdafabcba99))
14
+
6
15
  ## [3.100.7](https://github.com/vtex/faststore/compare/v3.100.6...v3.100.7) (2026-10-26)
7
16
 
8
17
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faststore/core",
3
- "version": "3.100.7",
3
+ "version": "3.100.8",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -127,5 +127,5 @@
127
127
  "ts-jest": "29.1.1",
128
128
  "typescript": "5.3.2"
129
129
  },
130
- "gitHead": "fc9b34b4fc943c17e225249677b49e393dc2d1f4"
130
+ "gitHead": "e742ac062ea5c0dd4df931f2431123bbbf973f34"
131
131
  }
@@ -2,6 +2,7 @@ import type { ContentData, ContentTypeOptions, Locator } from '@vtex/client-cms'
2
2
  import ClientCMS from '@vtex/client-cms'
3
3
 
4
4
  import MultipleContentError from 'src/sdk/error/MultipleContentError'
5
+ import { createPromiseCache } from 'src/server/content/promiseCache'
5
6
  import { sanitizeHost } from 'src/utils/utilities'
6
7
  import config from '../../../discovery.config'
7
8
 
@@ -71,16 +72,64 @@ export const clientCMS = new ClientCMS({
71
72
  host: sanitizeHost(config.storeUrl),
72
73
  })
73
74
 
75
+ type CMSPageResult = { data: ContentData[] }
76
+
77
+ export const cmsPageCache = createPromiseCache<CMSPageResult>()
78
+
79
+ type GetCMSPageOptions = {
80
+ /**
81
+ * Reuse a document fetched by locator. Only safe when the locator does not
82
+ * come from a preview, since preview versions change between requests.
83
+ */
84
+ cache?: boolean
85
+ }
86
+
87
+ /**
88
+ * Listings by content type never carry preview data (previews always resolve
89
+ * to a locator), so they are shared across pages. Filtered listings depend on
90
+ * the page (e.g. landing page slug) and stay uncached.
91
+ */
74
92
  export const getCMSPage = async (
75
93
  options: Options,
76
- cmsClient: ClientCMS = clientCMS
77
- ) => {
94
+ cmsClient: ClientCMS = clientCMS,
95
+ { cache = false }: GetCMSPageOptions = {}
96
+ ): Promise<CMSPageResult> => {
97
+ const shareable = cmsClient === clientCMS
98
+
78
99
  if (isLocator(options)) {
79
- return await cmsClient
80
- .getCMSPage(options)
81
- .then((page) => ({ data: [page] }))
100
+ const fetchByLocator = () =>
101
+ cmsClient.getCMSPage(options).then((page) => ({ data: [page] }))
102
+
103
+ if (!cache || !shareable) {
104
+ return fetchByLocator()
105
+ }
106
+
107
+ const { contentType, documentId, versionId, releaseId } = options
108
+ return cmsPageCache.get(
109
+ JSON.stringify([
110
+ 'locator',
111
+ contentType,
112
+ documentId,
113
+ versionId,
114
+ releaseId,
115
+ ]),
116
+ fetchByLocator
117
+ )
118
+ }
119
+
120
+ if (options.filters || !shareable) {
121
+ return listCMSPages(options, cmsClient)
82
122
  }
83
123
 
124
+ return cmsPageCache.get(JSON.stringify(['list', options.contentType]), () =>
125
+ listCMSPages(options, cmsClient)
126
+ )
127
+ }
128
+
129
+ const listCMSPages = async (
130
+ options: Exclude<Options, Locator>,
131
+ cmsClient: ClientCMS
132
+ ): Promise<CMSPageResult> => {
84
133
  const pages = []
85
134
  const page = 1
86
135
  const perPage = 10
@@ -116,8 +165,11 @@ export const getCMSPage = async (
116
165
  return { data: pages }
117
166
  }
118
167
 
119
- export const getPage = async <T extends ContentData>(options: Options) => {
120
- const result = await getCMSPage(options)
168
+ export const getPage = async <T extends ContentData>(
169
+ options: Options,
170
+ getOptions?: GetCMSPageOptions
171
+ ) => {
172
+ const result = await getCMSPage(options, clientCMS, getOptions)
121
173
 
122
174
  const pages = result.data
123
175
 
@@ -57,11 +57,16 @@ const getPDPFromCmsEnvData = async (
57
57
 
58
58
  const template = findBestPDPTemplate(pages, product)
59
59
 
60
- return getPage<PDPContentType>({
61
- contentType: 'pdp',
62
- documentId: template.documentId as string,
63
- versionId: template.versionId,
64
- })
60
+ // The template locator comes from the build-time CMS data, never from a
61
+ // preview, so the fetched document can be shared across page generations.
62
+ return getPage<PDPContentType>(
63
+ {
64
+ contentType: 'pdp',
65
+ documentId: template.documentId as string,
66
+ versionId: template.versionId,
67
+ },
68
+ { cache: true }
69
+ )
65
70
  }
66
71
 
67
72
  const getPDPFromCms = async (
@@ -71,11 +71,16 @@ const getPLPFromCmsEnvData = async (
71
71
 
72
72
  const template = findBestPLPTemplate(pages, slug, rewrites)
73
73
 
74
- return getPage<PLPContentType>({
75
- contentType: 'plp',
76
- documentId: template.documentId as string,
77
- versionId: template.versionId,
78
- })
74
+ // The template locator comes from the build-time CMS data, never from a
75
+ // preview, so the fetched document can be shared across page generations.
76
+ return getPage<PLPContentType>(
77
+ {
78
+ contentType: 'plp',
79
+ documentId: template.documentId as string,
80
+ versionId: template.versionId,
81
+ },
82
+ { cache: true }
83
+ )
79
84
  }
80
85
 
81
86
  export const getPLPFromCms = async (
@@ -0,0 +1,68 @@
1
+ // Bounds how stale shared CMS content can get in long-lived processes (ISR);
2
+ // a CMS publish already triggers a new build.
3
+ export const CONTENT_CACHE_TTL_MS = 5 * 60 * 1000
4
+
5
+ type PromiseCacheEntry<T> = {
6
+ expiresAt: number
7
+ promise: Promise<T>
8
+ }
9
+
10
+ /**
11
+ * Per-process memoization of async loaders. Concurrent callers share the
12
+ * in-flight promise, entries expire after `ttlMs`, and rejections are evicted
13
+ * so the next caller retries. When `shouldCache` rejects a resolved value, the
14
+ * entry is evicted too, so only the callers already waiting share that value.
15
+ * Expired entries are also dropped whenever a new one is stored, so keys that
16
+ * are never requested again (e.g. superseded document versions) do not pile up
17
+ * for the lifetime of the process.
18
+ */
19
+ export function createPromiseCache<T>(ttlMs = CONTENT_CACHE_TTL_MS) {
20
+ const entries = new Map<string, PromiseCacheEntry<T>>()
21
+
22
+ return {
23
+ get(
24
+ key: string,
25
+ load: () => Promise<T>,
26
+ shouldCache: (value: T) => boolean = () => true
27
+ ): Promise<T> {
28
+ const cached = entries.get(key)
29
+ if (cached && cached.expiresAt > Date.now()) {
30
+ return cached.promise
31
+ }
32
+
33
+ const evict = () => {
34
+ if (entries.get(key)?.promise === promise) {
35
+ entries.delete(key)
36
+ }
37
+ }
38
+
39
+ const now = Date.now()
40
+ entries.forEach((entry, entryKey) => {
41
+ if (entry.expiresAt <= now) entries.delete(entryKey)
42
+ })
43
+
44
+ const promise = load().then(
45
+ (value) => {
46
+ if (!shouldCache(value)) evict()
47
+ return value
48
+ },
49
+ (err: unknown) => {
50
+ evict()
51
+ throw err
52
+ }
53
+ )
54
+
55
+ entries.set(key, { expiresAt: now + ttlMs, promise })
56
+
57
+ return promise
58
+ },
59
+
60
+ clear() {
61
+ entries.clear()
62
+ },
63
+
64
+ get size() {
65
+ return entries.size
66
+ },
67
+ }
68
+ }
@@ -15,23 +15,24 @@ import MissingContentError from 'src/sdk/error/MissingContentError'
15
15
  import { getPDP, type PDPContentType } from '../cms/pdp'
16
16
  import MultipleContentError from 'src/sdk/error/MultipleContentError'
17
17
  import type { ServerProductQueryQuery } from '@generated/graphql'
18
+ import { createPromiseCache } from './promiseCache'
18
19
  import { isBranchPreview, isContentPlatformSource } from './utils'
19
20
 
20
21
  type ContentResult = ContentData | (ContentEntry & PageContentType)
21
22
 
22
- type ContentListCacheEntry = {
23
- expiresAt: number
24
- promise: Promise<{ data: ContentResult[] }>
25
- }
26
-
27
- // Bounds how stale the template list can get in long-lived processes (ISR);
28
- // a CMS publish already triggers a new build.
29
- const CONTENT_LIST_CACHE_TTL_MS = 5 * 60 * 1000
23
+ const OPTIONAL_CONTENT_TYPES = [
24
+ 'globalHeaderSections',
25
+ 'globalFooterSections',
26
+ ] as const
30
27
 
31
28
  export class ContentService {
32
29
  private clientCP: ClientCP
33
30
 
34
- private readonly contentListCache = new Map<string, ContentListCacheEntry>()
31
+ private readonly contentListCache = createPromiseCache<{
32
+ data: ContentResult[]
33
+ }>()
34
+
35
+ private readonly firstEntryCache = createPromiseCache<PageContentType>()
35
36
 
36
37
  constructor() {
37
38
  this.clientCP = new ClientCP({
@@ -47,7 +48,7 @@ export class ContentService {
47
48
  if (isContentPlatformSource()) {
48
49
  return this.getFromCP<T>(options)
49
50
  }
50
- return getPage(options.cmsOptions)
51
+ return getPage(options.cmsOptions, { cache: !options.isPreview })
51
52
  }
52
53
 
53
54
  async getMultipleContent(
@@ -118,10 +119,15 @@ export class ContentService {
118
119
  options: ContentOptions
119
120
  ): Promise<T> {
120
121
  const params = this.convertOptionsToParams(options)
122
+ // Filters are forwarded to listEntries, so a filtered lookup is not shared.
123
+ const shareable =
124
+ !options.isPreview &&
125
+ !('filters' in options.cmsOptions && options.cmsOptions.filters)
121
126
  try {
122
127
  const entry: PageContentType = await this.getEntry(
123
128
  params,
124
- options.isPreview
129
+ options.isPreview,
130
+ shareable
125
131
  )
126
132
  return entry as T
127
133
  } catch (err: unknown) {
@@ -133,30 +139,9 @@ export class ContentService {
133
139
  private getCachedContentList(
134
140
  serviceParams: EntryPathParams
135
141
  ): Promise<{ data: ContentResult[] }> {
136
- // listEntries only depends on these fields; the page slug must stay out of the key.
137
- const { accountName, storeId, contentType, locale } = serviceParams
138
- const key = JSON.stringify([accountName, storeId, contentType, locale])
139
-
140
- const cached = this.contentListCache.get(key)
141
- if (cached && cached.expiresAt > Date.now()) {
142
- return cached.promise
143
- }
144
-
145
- const promise = this.listAndFillEntries(serviceParams, false).catch(
146
- (err: unknown) => {
147
- if (this.contentListCache.get(key)?.promise === promise) {
148
- this.contentListCache.delete(key)
149
- }
150
- throw err
151
- }
142
+ return this.contentListCache.get(getContentTypeKey(serviceParams), () =>
143
+ this.listAndFillEntries(serviceParams, false)
152
144
  )
153
-
154
- this.contentListCache.set(key, {
155
- expiresAt: Date.now() + CONTENT_LIST_CACHE_TTL_MS,
156
- promise,
157
- })
158
-
159
- return promise
160
145
  }
161
146
 
162
147
  private async listAndFillEntries(
@@ -203,11 +188,24 @@ export class ContentService {
203
188
 
204
189
  private async getEntry(
205
190
  params: EntryPathParams,
206
- isPreview: boolean
191
+ isPreview: boolean,
192
+ shareable: boolean
207
193
  ): Promise<PageContentType> {
208
- return params.entryId || params.slug
209
- ? await this.getEntryData(params, isPreview)
210
- : await this.fetchFirstEntryFromList(params, isPreview)
194
+ if (params.entryId || params.slug) {
195
+ return this.getEntryData(params, isPreview)
196
+ }
197
+
198
+ if (!shareable) {
199
+ return this.fetchFirstEntryFromList(params, isPreview)
200
+ }
201
+
202
+ // An empty required type is likely content that is about to be published,
203
+ // so it is not kept for the whole TTL. Optional types are empty by design.
204
+ return this.firstEntryCache.get(
205
+ getContentTypeKey(params),
206
+ () => this.fetchFirstEntryFromList(params, false),
207
+ (entry) => !isEmptyEntry(entry) || isOptionalContentType(params)
208
+ )
211
209
  }
212
210
 
213
211
  private async getEntryData(
@@ -338,6 +336,26 @@ export class ContentService {
338
336
  }
339
337
  }
340
338
 
339
+ function isOptionalContentType({ contentType }: EntryPathParams): boolean {
340
+ return OPTIONAL_CONTENT_TYPES.includes(
341
+ contentType as (typeof OPTIONAL_CONTENT_TYPES)[number]
342
+ )
343
+ }
344
+
345
+ function isEmptyEntry(entry: PageContentType): boolean {
346
+ return Object.keys(entry).length === 0
347
+ }
348
+
349
+ // listEntries only depends on these fields; the page slug must stay out of the key.
350
+ function getContentTypeKey({
351
+ accountName,
352
+ storeId,
353
+ contentType,
354
+ locale,
355
+ }: EntryPathParams): string {
356
+ return JSON.stringify([accountName, storeId, contentType, locale])
357
+ }
358
+
341
359
  function isNotFoundError(err: unknown): boolean {
342
360
  if (err instanceof MissingContentError) return true
343
361
  if (err instanceof Error && /\b404\b/.test(err.message)) return true
@@ -1,4 +1,9 @@
1
- import { clientCMS, getCMSPage } from '../../../src/server/cms'
1
+ import {
2
+ clientCMS,
3
+ cmsPageCache,
4
+ getCMSPage,
5
+ getPage,
6
+ } from '../../../src/server/cms'
2
7
  import { jest } from '@jest/globals'
3
8
  import type { ContentData } from '@vtex/client-cms'
4
9
 
@@ -18,15 +23,32 @@ describe('CMS Integration', () => {
18
23
  return data
19
24
  }
20
25
 
26
+ const mockListing = (count = 1) => {
27
+ const mockFunction: jest.Mock<typeof clientCMS.getCMSPagesByContentType> =
28
+ jest.fn()
29
+ mockFunction.mockImplementation(() =>
30
+ Promise.resolve({
31
+ data: mockData(count),
32
+ hasNextPage: false,
33
+ totalItems: count,
34
+ })
35
+ )
36
+ return mockFunction
37
+ }
38
+
39
+ const mockLocator = () => {
40
+ const mockFunction: jest.Mock<typeof clientCMS.getCMSPage> = jest.fn()
41
+ mockFunction.mockImplementation(() => Promise.resolve(mockData(1)[0]))
42
+ return mockFunction
43
+ }
44
+
45
+ beforeEach(() => {
46
+ cmsPageCache.clear()
47
+ })
48
+
21
49
  describe('getCMSPage', () => {
22
50
  it('returns the first page if there is only one page', async () => {
23
- const mockFunction = jest.fn(() => {
24
- return Promise.resolve({
25
- data: mockData(3),
26
- hasNextPage: false,
27
- totalItems: 3,
28
- })
29
- })
51
+ const mockFunction = mockListing(3)
30
52
  clientCMS.getCMSPagesByContentType = mockFunction
31
53
 
32
54
  const result = await getCMSPage({ contentType: 'plp' }, clientCMS)
@@ -62,4 +84,86 @@ describe('CMS Integration', () => {
62
84
  expect(result.data.length).toBe(15)
63
85
  })
64
86
  })
87
+
88
+ describe('cache', () => {
89
+ it('shares a content type listing across concurrent callers', async () => {
90
+ const mockFunction = mockListing(3)
91
+ clientCMS.getCMSPagesByContentType = mockFunction
92
+
93
+ await Promise.all([
94
+ getCMSPage({ contentType: 'plp' }),
95
+ getCMSPage({ contentType: 'plp' }),
96
+ getCMSPage({ contentType: 'plp' }),
97
+ ])
98
+ await getCMSPage({ contentType: 'pdp' })
99
+
100
+ expect(
101
+ mockFunction.mock.calls.map(([contentType]) => contentType)
102
+ ).toEqual(['plp', 'pdp'])
103
+ })
104
+
105
+ it('does not cache filtered listings', async () => {
106
+ const mockFunction = mockListing(1)
107
+ clientCMS.getCMSPagesByContentType = mockFunction
108
+ const options = {
109
+ contentType: 'landingPage',
110
+ filters: { filters: { 'settings.seo.slug': '/summer-sale' } },
111
+ }
112
+
113
+ await getCMSPage(options)
114
+ await getCMSPage(options)
115
+
116
+ expect(mockFunction).toHaveBeenCalledTimes(2)
117
+ })
118
+
119
+ it('fetches a locator on every call by default', async () => {
120
+ const mockFunction = mockLocator()
121
+ clientCMS.getCMSPage = mockFunction
122
+ const locator = {
123
+ contentType: 'globalSections',
124
+ documentId: 'doc-1',
125
+ versionId: 'version-1',
126
+ }
127
+
128
+ await getPage(locator)
129
+ await getPage(locator)
130
+
131
+ expect(mockFunction).toHaveBeenCalledTimes(2)
132
+ })
133
+
134
+ it('reuses a locator fetch when caching is enabled', async () => {
135
+ const mockFunction = mockLocator()
136
+ clientCMS.getCMSPage = mockFunction
137
+ const locator = {
138
+ contentType: 'globalSections',
139
+ documentId: 'doc-1',
140
+ versionId: 'version-1',
141
+ }
142
+
143
+ const [first, second] = await Promise.all([
144
+ getPage(locator, { cache: true }),
145
+ getPage(locator, { cache: true }),
146
+ ])
147
+ await getPage({ ...locator, versionId: 'version-2' }, { cache: true })
148
+
149
+ expect(first).toEqual(second)
150
+ expect(mockFunction).toHaveBeenCalledTimes(2)
151
+ })
152
+
153
+ it('does not cache failures', async () => {
154
+ const error = new Error('Request failed with status code 500')
155
+ const mockFunction: jest.Mock<typeof clientCMS.getCMSPagesByContentType> =
156
+ jest.fn()
157
+ mockFunction
158
+ .mockRejectedValueOnce(error)
159
+ .mockResolvedValueOnce({ data: mockData(1), totalItems: 1 } as never)
160
+ clientCMS.getCMSPagesByContentType = mockFunction
161
+
162
+ await expect(getCMSPage({ contentType: 'plp' })).rejects.toBe(error)
163
+ const result = await getCMSPage({ contentType: 'plp' })
164
+
165
+ expect(result.data).toHaveLength(1)
166
+ expect(mockFunction).toHaveBeenCalledTimes(2)
167
+ })
168
+ })
65
169
  })