From 4aeb29805b68c9882032d68bf44e77ebc159140b Mon Sep 17 00:00:00 2001 From: alevochko Date: Wed, 15 Jul 2026 13:54:49 +0300 Subject: [PATCH] fix(payload): memoize version collection fields to bound flattenedFieldsCache growth --- .../versions/buildCollectionFields.spec.ts | 60 +++++++++++++++++++ .../src/versions/buildCollectionFields.ts | 32 ++++++++++ 2 files changed, 92 insertions(+) create mode 100644 packages/payload/src/versions/buildCollectionFields.spec.ts diff --git a/packages/payload/src/versions/buildCollectionFields.spec.ts b/packages/payload/src/versions/buildCollectionFields.spec.ts new file mode 100644 index 00000000000..a143df1c220 --- /dev/null +++ b/packages/payload/src/versions/buildCollectionFields.spec.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' + +import type { SanitizedCollectionConfig } from '../collections/config/types.js' +import type { SanitizedConfig } from '../config/types.js' + +import { buildVersionCollectionFields } from './buildCollectionFields.js' + +const makeCollection = (slug: string, drafts = true): SanitizedCollectionConfig => + ({ + slug, + fields: [ + { name: 'id', type: 'text' }, + { name: 'title', type: 'text' }, + ], + flattenedFields: [ + { name: 'id', type: 'text' }, + { name: 'title', type: 'text' }, + ], + versions: { drafts }, + }) as unknown as SanitizedCollectionConfig + +const config = { localization: false } as unknown as SanitizedConfig + +describe('buildVersionCollectionFields', () => { + it('returns a referentially stable array across calls for the same collection', () => { + const collection = makeCollection('posts') + const first = buildVersionCollectionFields(config, collection, true) + const second = buildVersionCollectionFields(config, collection, true) + // Same reference: the schema is built once and memoized, so identity-keyed + // downstream caches (flattenedFieldsCache) hit instead of growing per call. + expect(second).toBe(first) + }) + + it('caches flattened and unflattened variants separately', () => { + const collection = makeCollection('pages') + const flattened = buildVersionCollectionFields(config, collection, true) + const unflattened = buildVersionCollectionFields(config, collection, false) + expect(flattened).not.toBe(unflattened) + expect(buildVersionCollectionFields(config, collection, true)).toBe(flattened) + expect(buildVersionCollectionFields(config, collection, false)).toBe(unflattened) + }) + + it('memoizes per collection (distinct references for distinct collections)', () => { + const a = buildVersionCollectionFields(config, makeCollection('a'), true) + const b = buildVersionCollectionFields(config, makeCollection('b'), true) + expect(a).not.toBe(b) + }) + + it('still produces the expected version schema', () => { + const fields = buildVersionCollectionFields(config, makeCollection('shape'), true) + const names = fields.map((field) => ('name' in field ? field.name : undefined)) + expect(names).toEqual(expect.arrayContaining(['parent', 'version', 'createdAt', 'updatedAt', 'latest'])) + const version = fields.find((field) => 'name' in field && field.name === 'version') as { + fields: { name?: string }[] + } + // the `version` group mirrors the collection fields but drops the base `id` + expect(version.fields.some((field) => field.name === 'id')).toBe(false) + expect(version.fields.some((field) => field.name === 'title')).toBe(true) + }) +}) diff --git a/packages/payload/src/versions/buildCollectionFields.ts b/packages/payload/src/versions/buildCollectionFields.ts index 8b5c0206baa..aedf160dfc3 100644 --- a/packages/payload/src/versions/buildCollectionFields.ts +++ b/packages/payload/src/versions/buildCollectionFields.ts @@ -4,11 +4,37 @@ import type { Field, FlattenedField } from '../fields/config/types.js' import { hasAutosaveEnabled, hasDraftsEnabled } from '../utilities/getVersionsConfig.js' +// Cache the version schema per collection. It derives only from the (stable) +// collection config, so rebuilding a fresh array on every call is wasted work +// and — since `flattenAllFields` keys `flattenedFieldsCache` by array identity — +// leaks one cache entry per request. WeakMap so discarded configs are reclaimed; +// flattened/unflattened are cached separately (flatten changes the version group). +type VersionCollectionFieldsCacheEntry = { + flattened?: FlattenedField[] + unflattened?: Field[] +} + +const versionCollectionFieldsCache = new WeakMap< + SanitizedCollectionConfig, + VersionCollectionFieldsCacheEntry +>() + export const buildVersionCollectionFields = ( config: SanitizedConfig, collection: SanitizedCollectionConfig, flatten?: T, ): true extends T ? FlattenedField[] : Field[] => { + let cacheEntry = versionCollectionFieldsCache.get(collection) + if (!cacheEntry) { + cacheEntry = {} + versionCollectionFieldsCache.set(collection, cacheEntry) + } + + const cached = flatten ? cacheEntry.flattened : cacheEntry.unflattened + if (cached) { + return cached as true extends T ? FlattenedField[] : Field[] + } + const fields: FlattenedField[] = [ { name: 'parent', @@ -79,5 +105,11 @@ export const buildVersionCollectionFields = ( } } + if (flatten) { + cacheEntry.flattened = fields + } else { + cacheEntry.unflattened = fields as Field[] + } + return fields as true extends T ? FlattenedField[] : Field[] }