@faststore/core 3.100.6 → 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 +2 -2
  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/{TfWW2xib3Snr8xF4Ldqws → 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-6a844a3f6a390f62.js → webpack-62bcb2a452e15476.js} +1 -1
  32. package/.next/trace +156 -156
  33. package/.turbo/turbo-build.log +9 -9
  34. package/.turbo/turbo-test.log +12 -10
  35. package/CHANGELOG.md +15 -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 +93 -27
  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 +446 -0
  45. package/.next/static/chunks/3802.904f302bf009f795.js +0 -1
  46. /package/.next/static/{TfWW2xib3Snr8xF4Ldqws → 3we-qu8I2EmorLqlDX8oP}/_ssgManifest.js +0 -0
@@ -1,23 +1,23 @@
1
1
 
2
- > @faststore/core@3.100.4 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.4 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.4 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.4 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.4 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.4 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.4 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
@@ -103,8 +103,8 @@ Import trace for requested module:
103
103
  Search not found. Add a new component for this section or remove it from the CMS
104
104
  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.
105
105
  Search not found. Add a new component for this section or remove it from the CMS
106
-
107
106
  Generating static pages (4/8)
108
107
  Search not found. Add a new component for this section or remove it from the CMS
108
+
109
109
  Generating static pages (4/8)
110
110
  Search not found. Add a new component for this section or remove it from the CMS
111
111
 
112
112
  Generating static pages (6/8)
113
113
  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-6a844a3f6a390f62.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,22 +1,24 @@
1
1
 
2
- > @faststore/core@3.100.4 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/utils/clearCookies.test.ts (13.987 s)
6
- PASS test/server/password-protection-service.test.ts (18.623 s)
7
- PASS test/utils/cookieCacheBusting.test.ts (16.597 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
+ PASS test/utils/clearCookies.test.ts
10
+ PASS test/server/cms/index.test.ts
9
11
  PASS test/sdk/redirects/index.test.ts
10
12
  PASS test/utils/trustedForwardedHost.test.ts
11
- PASS test/utils/validateSessionRefreshToken.test.ts
13
+ PASS test/server/content/promiseCache.test.ts
12
14
  PASS test/server/cms/global.test.ts
15
+ PASS test/utils/validateSessionRefreshToken.test.ts
13
16
  PASS test/utils/isLocalHost.test.ts
14
17
  PASS test/utils/isLocalHost.browser.test.ts
15
- PASS test/server/cms/index.test.ts (5.82 s)
16
- PASS test/server/index.test.ts (10.891 s)
18
+ PASS test/server/index.test.ts (5.995 s)
17
19
 
18
- Test Suites: 12 passed, 12 total
19
- Tests: 119 passed, 119 total
20
+ Test Suites: 14 passed, 14 total
21
+ Tests: 155 passed, 155 total
20
22
  Snapshots: 0 total
21
- Time: 30.388 s
23
+ Time: 21.421 s
22
24
  Ran all test suites.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,21 @@
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
+
15
+ ## [3.100.7](https://github.com/vtex/faststore/compare/v3.100.6...v3.100.7) (2026-10-26)
16
+
17
+ ### Bug Fixes
18
+
19
+ - **core:** cache Content Platform template list across page generations (3.x) ([#3534](https://github.com/vtex/faststore/issues/3534)) ([825c176](https://github.com/vtex/faststore/commit/825c176357b0f40ac9d112ee9789dce0c8198cf0)), closes [#3526](https://github.com/vtex/faststore/issues/3526) [#3384](https://github.com/vtex/faststore/issues/3384) [#3526](https://github.com/vtex/faststore/issues/3526) [#3526](https://github.com/vtex/faststore/issues/3526) [#3384](https://github.com/vtex/faststore/issues/3384)
20
+
6
21
  ## [3.100.6](https://github.com/vtex/faststore/compare/v3.100.5...v3.100.6) (2026-10-07)
7
22
 
8
23
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faststore/core",
3
- "version": "3.100.6",
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": "31df58321c484732bd4a69618e9eb09e6a2c0261"
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,13 +15,25 @@ 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
 
23
+ const OPTIONAL_CONTENT_TYPES = [
24
+ 'globalHeaderSections',
25
+ 'globalFooterSections',
26
+ ] as const
27
+
22
28
  export class ContentService {
23
29
  private clientCP: ClientCP
24
30
 
31
+ private readonly contentListCache = createPromiseCache<{
32
+ data: ContentResult[]
33
+ }>()
34
+
35
+ private readonly firstEntryCache = createPromiseCache<PageContentType>()
36
+
25
37
  constructor() {
26
38
  this.clientCP = new ClientCP({
27
39
  tenant: config.api.storeId,
@@ -36,7 +48,7 @@ export class ContentService {
36
48
  if (isContentPlatformSource()) {
37
49
  return this.getFromCP<T>(options)
38
50
  }
39
- return getPage(options.cmsOptions)
51
+ return getPage(options.cmsOptions, { cache: !options.isPreview })
40
52
  }
41
53
 
42
54
  async getMultipleContent(
@@ -46,29 +58,12 @@ export class ContentService {
46
58
 
47
59
  if (isContentPlatformSource()) {
48
60
  const serviceParams = this.convertOptionsToParams(options)
49
- // TODO: This should be temporary until the cp data-plane have a search engine.
50
- const MAX_PAGES = 25
51
- const allEntries: ContentEntry[] = []
52
- let scroll: string | undefined
53
- let pages = 0
54
-
55
- do {
56
- const result = await this.clientCP.listEntries(serviceParams, scroll)
57
- allEntries.push(...result.entries)
58
- const next = result.scroll ?? undefined
59
- if (next && next === scroll) break
60
- scroll = next
61
- } while (scroll && ++pages < MAX_PAGES)
62
-
63
- if (pages >= MAX_PAGES) {
64
- console.warn('listEntries pagination hit MAX_PAGES cap', serviceParams)
61
+
62
+ if (options.isPreview) {
63
+ return this.listAndFillEntries(serviceParams, true)
65
64
  }
66
65
 
67
- return this.fillEntriesWithData(
68
- allEntries,
69
- serviceParams,
70
- options.isPreview
71
- )
66
+ return this.getCachedContentList(serviceParams)
72
67
  }
73
68
  return getCMSPage(options.cmsOptions)
74
69
  }
@@ -124,10 +119,15 @@ export class ContentService {
124
119
  options: ContentOptions
125
120
  ): Promise<T> {
126
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)
127
126
  try {
128
127
  const entry: PageContentType = await this.getEntry(
129
128
  params,
130
- options.isPreview
129
+ options.isPreview,
130
+ shareable
131
131
  )
132
132
  return entry as T
133
133
  } catch (err: unknown) {
@@ -136,6 +136,39 @@ export class ContentService {
136
136
  }
137
137
  }
138
138
 
139
+ private getCachedContentList(
140
+ serviceParams: EntryPathParams
141
+ ): Promise<{ data: ContentResult[] }> {
142
+ return this.contentListCache.get(getContentTypeKey(serviceParams), () =>
143
+ this.listAndFillEntries(serviceParams, false)
144
+ )
145
+ }
146
+
147
+ private async listAndFillEntries(
148
+ serviceParams: EntryPathParams,
149
+ isPreview: boolean
150
+ ): Promise<{ data: (ContentEntry & PageContentType)[] }> {
151
+ // TODO: This should be temporary until the cp data-plane have a search engine.
152
+ const MAX_PAGES = 25
153
+ const allEntries: ContentEntry[] = []
154
+ let scroll: string | undefined
155
+ let pages = 0
156
+
157
+ do {
158
+ const result = await this.clientCP.listEntries(serviceParams, scroll)
159
+ allEntries.push(...result.entries)
160
+ const next = result.scroll ?? undefined
161
+ if (next && next === scroll) break
162
+ scroll = next
163
+ } while (scroll && ++pages < MAX_PAGES)
164
+
165
+ if (pages >= MAX_PAGES) {
166
+ console.warn('listEntries pagination hit MAX_PAGES cap', serviceParams)
167
+ }
168
+
169
+ return this.fillEntriesWithData(allEntries, serviceParams, isPreview)
170
+ }
171
+
139
172
  private async fillEntriesWithData(
140
173
  entries: ContentEntry[],
141
174
  serviceParams: EntryPathParams,
@@ -155,11 +188,24 @@ export class ContentService {
155
188
 
156
189
  private async getEntry(
157
190
  params: EntryPathParams,
158
- isPreview: boolean
191
+ isPreview: boolean,
192
+ shareable: boolean
159
193
  ): Promise<PageContentType> {
160
- return params.entryId || params.slug
161
- ? await this.getEntryData(params, isPreview)
162
- : 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
+ )
163
209
  }
164
210
 
165
211
  private async getEntryData(
@@ -290,6 +336,26 @@ export class ContentService {
290
336
  }
291
337
  }
292
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
+
293
359
  function isNotFoundError(err: unknown): boolean {
294
360
  if (err instanceof MissingContentError) return true
295
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
  })