From 8e950053c33bc2d7b71ac227c4fc9e56e99c7c53 Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Fri, 6 Nov 2026 10:33:58 -0500 Subject: [PATCH 1/8] fix: deep-copy omitted field values from the original document --- .../cloneDataFromOriginalDoc.spec.ts | 33 + .../beforeChange/cloneDataFromOriginalDoc.ts | 20 +- .../collections/FallbackValues/index.ts | 35 ++ test/field-fallback/config.ts | 16 + test/field-fallback/int.spec.ts | 106 ++++ test/field-fallback/payload-types.ts | 566 ++++++++++++++++++ test/field-fallback/shared.ts | 1 + test/field-fallback/tsconfig.json | 4 + 8 files changed, 764 insertions(+), 17 deletions(-) create mode 100644 packages/payload/src/fields/hooks/beforeChange/cloneDataFromOriginalDoc.spec.ts create mode 100644 test/field-fallback/collections/FallbackValues/index.ts create mode 100644 test/field-fallback/config.ts create mode 100644 test/field-fallback/int.spec.ts create mode 100644 test/field-fallback/payload-types.ts create mode 100644 test/field-fallback/shared.ts create mode 100644 test/field-fallback/tsconfig.json diff --git a/packages/payload/src/fields/hooks/beforeChange/cloneDataFromOriginalDoc.spec.ts b/packages/payload/src/fields/hooks/beforeChange/cloneDataFromOriginalDoc.spec.ts new file mode 100644 index 00000000000..b1a1012d3a1 --- /dev/null +++ b/packages/payload/src/fields/hooks/beforeChange/cloneDataFromOriginalDoc.spec.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' + +import { cloneDataFromOriginalDoc } from './cloneDataFromOriginalDoc.js' + +describe('cloneDataFromOriginalDoc', () => { + it('should preserve nested arrays and mixed JSON values', () => { + const original = [[100, 200], [[300, 400]], { coordinates: [[500, 600]] }, null, true] + + const cloned = cloneDataFromOriginalDoc(original) + + expect(cloned).toStrictEqual(original) + }) + + it('should isolate nested array rows from the original document', () => { + const original = [{ children: [{ code: 'stored' }] }] + + const cloned = cloneDataFromOriginalDoc(original) as typeof original + + Reflect.deleteProperty(cloned[0].children[0], 'code') + + expect(original[0].children[0].code).toBe('stored') + }) + + it('should isolate nested objects from the original document', () => { + const original = { group: { coordinates: [[100, 200]] } } + + const cloned = cloneDataFromOriginalDoc(original) as typeof original + + cloned.group.coordinates[0][0] = 300 + + expect(original.group.coordinates).toStrictEqual([[100, 200]]) + }) +}) diff --git a/packages/payload/src/fields/hooks/beforeChange/cloneDataFromOriginalDoc.ts b/packages/payload/src/fields/hooks/beforeChange/cloneDataFromOriginalDoc.ts index 0aadae2dc34..c668c77d9a6 100644 --- a/packages/payload/src/fields/hooks/beforeChange/cloneDataFromOriginalDoc.ts +++ b/packages/payload/src/fields/hooks/beforeChange/cloneDataFromOriginalDoc.ts @@ -1,23 +1,9 @@ import type { JsonArray, JsonObject } from '../../../types/index.js' +import { deepCopyObjectSimple } from '../../../utilities/deepCopyObject.js' + export const cloneDataFromOriginalDoc = ( originalDocData: JsonArray | JsonObject, ): JsonArray | JsonObject => { - if (Array.isArray(originalDocData)) { - return originalDocData.map((row) => { - if (typeof row === 'object' && row != null) { - return { - ...row, - } - } - - return row - }) - } - - if (typeof originalDocData === 'object' && originalDocData !== null) { - return { ...originalDocData } - } - - return originalDocData + return deepCopyObjectSimple(originalDocData) } diff --git a/test/field-fallback/collections/FallbackValues/index.ts b/test/field-fallback/collections/FallbackValues/index.ts new file mode 100644 index 00000000000..6651040d4be --- /dev/null +++ b/test/field-fallback/collections/FallbackValues/index.ts @@ -0,0 +1,35 @@ +import type { CollectionConfig } from 'payload' + +import { fallbackValuesSlug } from '../../shared.js' + +export const FallbackValues: CollectionConfig = { + slug: fallbackValuesSlug, + access: { + create: () => true, + read: () => true, + update: () => true, + }, + fields: [ + { name: 'title', type: 'text' }, + { name: 'json', type: 'json' }, + { + name: 'rows', + type: 'array', + fields: [ + { + name: 'children', + type: 'array', + fields: [ + { + name: 'code', + type: 'text', + access: { update: () => false }, + defaultValue: 'default', + }, + ], + }, + ], + }, + ], + versions: { drafts: true }, +} diff --git a/test/field-fallback/config.ts b/test/field-fallback/config.ts new file mode 100644 index 00000000000..7b3aca5651e --- /dev/null +++ b/test/field-fallback/config.ts @@ -0,0 +1,16 @@ +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' +import { FallbackValues } from './collections/FallbackValues/index.js' + +const dirname = path.dirname(fileURLToPath(import.meta.url)) + +export default buildConfigWithDefaults({ + suite: 'field-fallback', + disableMCP: true, + config: { + collections: [FallbackValues], + typescript: { outputFile: path.resolve(dirname, 'payload-types.ts') }, + }, +}) diff --git a/test/field-fallback/int.spec.ts b/test/field-fallback/int.spec.ts new file mode 100644 index 00000000000..55837abaad3 --- /dev/null +++ b/test/field-fallback/int.spec.ts @@ -0,0 +1,106 @@ +import { expect } from 'vitest' + +import { test } from '../__helpers/int/vitest.js' +import { fallbackValuesSlug } from './shared.js' + +test.suite('Field fallback values', { config: './config.ts' }, () => { + for (const draft of [false, true]) { + const operation = draft ? 'draft save' : 'published update' + + test(`should preserve omitted JSON tuples after a ${operation}`, async ({ payload }) => { + const doc = await payload.create({ + collection: fallbackValuesSlug, + data: { + _status: draft ? 'draft' : 'published', + json: [ + [100, 200], + [300, 400], + ], + title: 'original', + }, + draft, + }) + + await payload.update({ + id: doc.id, + collection: fallbackValuesSlug, + data: { title: 'updated' }, + draft, + }) + + const stored = await payload.findByID({ + id: doc.id, + collection: fallbackValuesSlug, + draft, + }) + + expect(stored.json).toStrictEqual([ + [100, 200], + [300, 400], + ]) + }) + + test(`should preserve omitted protected nested fields after a ${operation}`, async ({ + payload, + }) => { + const doc = await payload.create({ + collection: fallbackValuesSlug, + data: { + _status: draft ? 'draft' : 'published', + rows: [{ children: [{ code: 'stored' }] }], + title: 'original', + }, + draft, + }) + + await payload.update({ + id: doc.id, + collection: fallbackValuesSlug, + data: { title: 'updated' }, + draft, + overrideAccess: false, + }) + + const stored = await payload.findByID({ + id: doc.id, + collection: fallbackValuesSlug, + draft, + }) + + expect(stored.rows?.[0]?.children?.[0]?.code).toBe('stored') + }) + + test(`should reject changes to protected nested fields during a ${operation}`, async ({ + payload, + }) => { + const doc = await payload.create({ + collection: fallbackValuesSlug, + data: { + _status: draft ? 'draft' : 'published', + rows: [{ children: [{ code: 'stored' }] }], + }, + draft, + }) + + const row = doc.rows![0]! + + await payload.update({ + id: doc.id, + collection: fallbackValuesSlug, + data: { + rows: [{ id: row.id, children: [{ id: row.children![0]!.id, code: 'changed' }] }], + }, + draft, + overrideAccess: false, + }) + + const stored = await payload.findByID({ + id: doc.id, + collection: fallbackValuesSlug, + draft, + }) + + expect(stored.rows?.[0]?.children?.[0]?.code).toBe('stored') + }) + } +}) diff --git a/test/field-fallback/payload-types.ts b/test/field-fallback/payload-types.ts new file mode 100644 index 00000000000..45643cf12f0 --- /dev/null +++ b/test/field-fallback/payload-types.ts @@ -0,0 +1,566 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * This file was automatically generated by Payload. + * DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config, + * and re-run `payload generate:types` to regenerate this file. + */ + +/** + * Supported timezones in IANA format. + * + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "supportedTimezones". + */ +export type SupportedTimezones = + | 'Pacific/Midway' + | 'Pacific/Niue' + | 'Pacific/Honolulu' + | 'Pacific/Rarotonga' + | 'America/Anchorage' + | 'Pacific/Gambier' + | 'America/Los_Angeles' + | 'America/Tijuana' + | 'America/Denver' + | 'America/Phoenix' + | 'America/Chicago' + | 'America/Guatemala' + | 'America/New_York' + | 'America/Bogota' + | 'America/Caracas' + | 'America/Santiago' + | 'America/Buenos_Aires' + | 'America/Sao_Paulo' + | 'Atlantic/South_Georgia' + | 'Atlantic/Azores' + | 'Atlantic/Cape_Verde' + | 'Europe/London' + | 'Europe/Berlin' + | 'Africa/Lagos' + | 'Europe/Athens' + | 'Africa/Cairo' + | 'Europe/Moscow' + | 'Asia/Riyadh' + | 'Asia/Dubai' + | 'Asia/Baku' + | 'Asia/Karachi' + | 'Asia/Tashkent' + | 'Asia/Calcutta' + | 'Asia/Dhaka' + | 'Asia/Almaty' + | 'Asia/Jakarta' + | 'Asia/Bangkok' + | 'Asia/Shanghai' + | 'Asia/Singapore' + | 'Asia/Tokyo' + | 'Asia/Seoul' + | 'Australia/Brisbane' + | 'Australia/Sydney' + | 'Pacific/Guam' + | 'Pacific/Noumea' + | 'Pacific/Auckland' + | 'Pacific/Fiji'; +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "LexicalNodes_3DE324E4". + */ +export type LexicalNodes_3DE324E4 = + | SerializedTextNode + | SerializedTabNode + | SerializedLineBreakNode + | SerializedParagraphNode + | SerializedListNode + | SerializedListItemNode + | SerializedHeadingNode; + +export interface Config { + auth: { + users: UserAuthOperations; + }; + blocks: {}; + collections: { + 'fallback-values': FallbackValue; + 'payload-kv': PayloadKv; + users: User; + 'payload-locked-documents': PayloadLockedDocument; + 'payload-preferences': PayloadPreference; + 'payload-migrations': PayloadMigration; + 'payload-llm-instructions': PayloadLlmInstruction; + }; + collectionsJoins: {}; + collectionsSelect: { + 'fallback-values': FallbackValuesSelect | FallbackValuesSelect; + 'payload-kv': PayloadKvSelect | PayloadKvSelect; + users: UsersSelect | UsersSelect; + 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; + 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; + 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; + 'payload-llm-instructions': PayloadLlmInstructionsSelect | PayloadLlmInstructionsSelect; + }; + db: { + defaultIDType: number; + }; + fallbackLocale: null; + globals: {}; + globalsSelect: {}; + locale: null; + widgets: { + welcome: WelcomeWidget; + collections: CollectionsWidget; + 'collection-query': CollectionQueryWidget; + activity: ActivityWidget; + }; + user: User; + jobs: { + tasks: unknown; + workflows: unknown; + }; +} +export interface UserAuthOperations { + forgotPassword: { + email: string; + password: string; + }; + login: { + email: string; + password: string; + }; + registerFirstUser: { + email: string; + password: string; + }; + unlock: { + email: string; + password: string; + }; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "fallback-values". + */ +export interface FallbackValue { + id: number; + title?: string | null; + json?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + rows?: + | { + children?: + | { + code?: string | null; + id?: string | null; + }[] + | null; + id?: string | null; + }[] + | null; + createdBy?: { + relationTo: 'users'; + value: number | User; + } | null; + updatedBy?: { + relationTo: 'users'; + value: number | User; + } | null; + updatedAt: string; + createdAt: string; + _status?: ('draft' | 'published') | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "users". + */ +export interface User { + id: number; + createdBy?: { + relationTo: 'users'; + value: number | User; + } | null; + updatedBy?: { + relationTo: 'users'; + value: number | User; + } | null; + updatedAt: string; + createdAt: string; + email: string; + resetPasswordToken?: string | null; + resetPasswordExpiration?: string | null; + salt?: string | null; + hash?: string | null; + resetPasswordRequestedAt?: string | null; + loginAttempts?: number | null; + lockUntil?: string | null; + sessions?: + | { + id: string; + createdAt?: string | null; + expiresAt: string; + refreshCount?: number | null; + refreshedAt?: string | null; + }[] + | null; + password?: string | null; + collection: 'users'; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-kv". + */ +export interface PayloadKv { + id: number; + key: string; + data: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-locked-documents". + */ +export interface PayloadLockedDocument { + id: number; + document?: + | ({ + relationTo: 'fallback-values'; + value: number | FallbackValue; + } | null) + | ({ + relationTo: 'users'; + value: number | User; + } | null); + globalSlug?: string | null; + user: { + relationTo: 'users'; + value: number | User; + }; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-preferences". + */ +export interface PayloadPreference { + id: number; + user: { + relationTo: 'users'; + value: number | User; + }; + key?: string | null; + value?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-migrations". + */ +export interface PayloadMigration { + id: number; + name?: string | null; + batch?: number | null; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-llm-instructions". + */ +export interface PayloadLlmInstruction { + id: string; + entitySlug: string; + title?: string | null; + entityType: 'collection' | 'global'; + additionalInstructions?: LexicalRichText | null; + systemInstructions?: LexicalRichText | null; + createdBy?: { + relationTo: 'users'; + value: number | User; + } | null; + updatedBy?: { + relationTo: 'users'; + value: number | User; + } | null; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "fallback-values_select". + */ +export interface FallbackValuesSelect { + title?: T; + json?: T; + rows?: + | T + | { + children?: + | T + | { + code?: T; + id?: T; + }; + id?: T; + }; + createdBy?: T; + updatedBy?: T; + updatedAt?: T; + createdAt?: T; + _status?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-kv_select". + */ +export interface PayloadKvSelect { + key?: T; + data?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "users_select". + */ +export interface UsersSelect { + createdBy?: T; + updatedBy?: T; + updatedAt?: T; + createdAt?: T; + email?: T; + resetPasswordToken?: T; + resetPasswordExpiration?: T; + salt?: T; + hash?: T; + resetPasswordRequestedAt?: T; + loginAttempts?: T; + lockUntil?: T; + sessions?: + | T + | { + id?: T; + createdAt?: T; + expiresAt?: T; + refreshCount?: T; + refreshedAt?: T; + }; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-locked-documents_select". + */ +export interface PayloadLockedDocumentsSelect { + document?: T; + globalSlug?: T; + user?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-preferences_select". + */ +export interface PayloadPreferencesSelect { + user?: T; + key?: T; + value?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-migrations_select". + */ +export interface PayloadMigrationsSelect { + name?: T; + batch?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-llm-instructions_select". + */ +export interface PayloadLlmInstructionsSelect { + id?: T; + entitySlug?: T; + title?: T; + entityType?: T; + additionalInstructions?: T; + systemInstructions?: T; + createdBy?: T; + updatedBy?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "welcome_widget". + */ +export interface WelcomeWidget { + data?: { + [k: string]: unknown; + }; + width: 'full'; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "collections_widget". + */ +export interface CollectionsWidget { + data?: { + [k: string]: unknown; + }; + width: 'full'; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "collection-query_widget". + */ +export interface CollectionQueryWidget { + data?: { + title?: string | null; + relatedCollection: 'fallback-values' | 'users'; + where?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + sortField?: string | null; + sortDirection?: ('asc' | 'desc') | null; + limit?: number | null; + }; + width: 'x-small' | 'small' | 'medium' | 'large' | 'x-large' | 'full'; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "activity_widget". + */ +export interface ActivityWidget { + data?: { + excludedCollections?: ('fallback-values' | 'users')[] | null; + }; + width: 'small' | 'medium' | 'large' | 'x-large' | 'full'; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "auth". + */ +export interface Auth { + [k: string]: unknown; +} + +/** @internal Core Lexical types — see @payloadcms/richtext-lexical. */ +export type LexicalElementFormat = 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; +export type LexicalElementDirection = ('ltr' | 'rtl') | null; + +export interface SerializedLexicalElementBase { + children: TChildren[]; + direction: LexicalElementDirection; + format: LexicalElementFormat; + indent: number; + textFormat?: number; + textStyle?: string; + /** @deprecated Ignored when loading. Typed as required only to match Lexical's types: rich text sent through the API, CLI or MCP may not contain it. */ + version: number; +} + +export type LexicalTextMode = 'normal' | 'token' | 'segmented'; + +export interface SerializedTextNode { + type: 'text'; + detail: number; + format: number; + mode: LexicalTextMode; + style: string; + text: string; + /** @deprecated Ignored when loading. Typed as required only to match Lexical's types: rich text sent through the API, CLI or MCP may not contain it. */ + version: number; +} + +export interface SerializedTabNode { + type: 'tab'; + detail: number; + format: number; + mode: LexicalTextMode; + style: string; + text: string; + /** @deprecated Ignored when loading. Typed as required only to match Lexical's types: rich text sent through the API, CLI or MCP may not contain it. */ + version: number; +} + +export interface SerializedLineBreakNode { + type: 'linebreak'; + /** @deprecated Ignored when loading. Typed as required only to match Lexical's types: rich text sent through the API, CLI or MCP may not contain it. */ + version: number; +} + +export interface SerializedParagraphNode extends SerializedLexicalElementBase { + type: 'paragraph'; + textFormat: number; + textStyle: string; +} + +export interface SerializedListNode extends SerializedLexicalElementBase { + type: 'list'; + checked?: boolean; + listType: 'number' | 'bullet' | 'check'; + start: number; + tag: 'ul' | 'ol'; +} + +export interface SerializedListItemNode extends SerializedLexicalElementBase { + type: 'listitem'; + checked?: boolean; + value: number; +} + +export interface SerializedHeadingNode< + TChildren, + TTag extends 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6', +> extends SerializedLexicalElementBase { + type: 'heading'; + tag: TTag; +} + +/** Shape of a Lexical `richText` field. */ +export interface LexicalRichText { + root: { + children: TNode[]; + direction: LexicalElementDirection; + format: LexicalElementFormat; + indent: number; + type: 'root'; + /** @deprecated Ignored when loading. Typed as required only to match Lexical's types: rich text sent through the API, CLI or MCP may not contain it. */ + version: number; + }; +} + + +declare module 'payload' { + // @ts-ignore + export interface GeneratedTypes extends Config {} +} \ No newline at end of file diff --git a/test/field-fallback/shared.ts b/test/field-fallback/shared.ts new file mode 100644 index 00000000000..7d7f29a8669 --- /dev/null +++ b/test/field-fallback/shared.ts @@ -0,0 +1 @@ +export const fallbackValuesSlug = 'fallback-values' diff --git a/test/field-fallback/tsconfig.json b/test/field-fallback/tsconfig.json new file mode 100644 index 00000000000..b29da4479f9 --- /dev/null +++ b/test/field-fallback/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.json", + "include": ["./**/*.ts", "./**/*.tsx"] +} From e5682317344ba654563e7256ff0e317e246996c3 Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Fri, 6 Nov 2026 10:34:30 -0500 Subject: [PATCH 2/8] chore: normalize generated test types whitespace --- test/field-fallback/payload-types.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/field-fallback/payload-types.ts b/test/field-fallback/payload-types.ts index 45643cf12f0..0b0538d22da 100644 --- a/test/field-fallback/payload-types.ts +++ b/test/field-fallback/payload-types.ts @@ -561,6 +561,6 @@ export interface LexicalRichText { declare module 'payload' { - // @ts-ignore + // @ts-ignore export interface GeneratedTypes extends Config {} -} \ No newline at end of file +} From dfe62f02a2deb1c6290c4b7ead6fc2870e995181 Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Fri, 6 Nov 2026 12:28:31 -0500 Subject: [PATCH 3/8] chore: rely on field fallback integration coverage --- .../cloneDataFromOriginalDoc.spec.ts | 33 ------------------- 1 file changed, 33 deletions(-) delete mode 100644 packages/payload/src/fields/hooks/beforeChange/cloneDataFromOriginalDoc.spec.ts diff --git a/packages/payload/src/fields/hooks/beforeChange/cloneDataFromOriginalDoc.spec.ts b/packages/payload/src/fields/hooks/beforeChange/cloneDataFromOriginalDoc.spec.ts deleted file mode 100644 index b1a1012d3a1..00000000000 --- a/packages/payload/src/fields/hooks/beforeChange/cloneDataFromOriginalDoc.spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { cloneDataFromOriginalDoc } from './cloneDataFromOriginalDoc.js' - -describe('cloneDataFromOriginalDoc', () => { - it('should preserve nested arrays and mixed JSON values', () => { - const original = [[100, 200], [[300, 400]], { coordinates: [[500, 600]] }, null, true] - - const cloned = cloneDataFromOriginalDoc(original) - - expect(cloned).toStrictEqual(original) - }) - - it('should isolate nested array rows from the original document', () => { - const original = [{ children: [{ code: 'stored' }] }] - - const cloned = cloneDataFromOriginalDoc(original) as typeof original - - Reflect.deleteProperty(cloned[0].children[0], 'code') - - expect(original[0].children[0].code).toBe('stored') - }) - - it('should isolate nested objects from the original document', () => { - const original = { group: { coordinates: [[100, 200]] } } - - const cloned = cloneDataFromOriginalDoc(original) as typeof original - - cloned.group.coordinates[0][0] = 300 - - expect(original.group.coordinates).toStrictEqual([[100, 200]]) - }) -}) From 002b9e9d7bdb39700b1aa123b8afae4134184f62 Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Fri, 6 Nov 2026 12:30:27 -0500 Subject: [PATCH 4/8] chore: use deep copy helper directly for fallback values --- .../hooks/beforeChange/cloneDataFromOriginalDoc.ts | 9 --------- .../src/fields/hooks/beforeValidate/getFallbackValue.ts | 4 ++-- 2 files changed, 2 insertions(+), 11 deletions(-) delete mode 100644 packages/payload/src/fields/hooks/beforeChange/cloneDataFromOriginalDoc.ts diff --git a/packages/payload/src/fields/hooks/beforeChange/cloneDataFromOriginalDoc.ts b/packages/payload/src/fields/hooks/beforeChange/cloneDataFromOriginalDoc.ts deleted file mode 100644 index c668c77d9a6..00000000000 --- a/packages/payload/src/fields/hooks/beforeChange/cloneDataFromOriginalDoc.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { JsonArray, JsonObject } from '../../../types/index.js' - -import { deepCopyObjectSimple } from '../../../utilities/deepCopyObject.js' - -export const cloneDataFromOriginalDoc = ( - originalDocData: JsonArray | JsonObject, -): JsonArray | JsonObject => { - return deepCopyObjectSimple(originalDocData) -} diff --git a/packages/payload/src/fields/hooks/beforeValidate/getFallbackValue.ts b/packages/payload/src/fields/hooks/beforeValidate/getFallbackValue.ts index 2bf3c928a78..b3c72725bf1 100644 --- a/packages/payload/src/fields/hooks/beforeValidate/getFallbackValue.ts +++ b/packages/payload/src/fields/hooks/beforeValidate/getFallbackValue.ts @@ -1,8 +1,8 @@ import type { JsonObject, JsonValue, PayloadRequest } from '../../../types/index.js' import type { FieldAffectingData } from '../../config/types.js' +import { deepCopyObjectSimple } from '../../../utilities/deepCopyObject.js' import { getDefaultValue } from '../../getDefaultValue.js' -import { cloneDataFromOriginalDoc } from '../beforeChange/cloneDataFromOriginalDoc.js' export async function getFallbackValue({ field, @@ -18,7 +18,7 @@ export async function getFallbackValue({ let fallbackValue: JsonValue = undefined if ('name' in field && field.name) { if (isDocumentValueAllowed && typeof siblingDoc[field.name] !== 'undefined') { - fallbackValue = cloneDataFromOriginalDoc(siblingDoc[field.name]) + fallbackValue = deepCopyObjectSimple(siblingDoc[field.name]) } else if ('defaultValue' in field && typeof field.defaultValue !== 'undefined') { fallbackValue = await getDefaultValue({ defaultValue: field.defaultValue, From 282a4ee05502d6b0005227ff19b078141843aa4d Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Fri, 6 Nov 2026 12:31:10 -0500 Subject: [PATCH 5/8] chore: explain fallback value mutation isolation --- .../payload/src/fields/hooks/beforeValidate/getFallbackValue.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/payload/src/fields/hooks/beforeValidate/getFallbackValue.ts b/packages/payload/src/fields/hooks/beforeValidate/getFallbackValue.ts index b3c72725bf1..53b9e5fe3a3 100644 --- a/packages/payload/src/fields/hooks/beforeValidate/getFallbackValue.ts +++ b/packages/payload/src/fields/hooks/beforeValidate/getFallbackValue.ts @@ -18,6 +18,7 @@ export async function getFallbackValue({ let fallbackValue: JsonValue = undefined if ('name' in field && field.name) { if (isDocumentValueAllowed && typeof siblingDoc[field.name] !== 'undefined') { + // Keep nested mutations from hooks and access control from changing the original document. fallbackValue = deepCopyObjectSimple(siblingDoc[field.name]) } else if ('defaultValue' in field && typeof field.defaultValue !== 'undefined') { fallbackValue = await getDefaultValue({ From 0a1a4ca22282af69a3be9addf5d1104d4960db0c Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Fri, 6 Nov 2026 12:37:32 -0500 Subject: [PATCH 6/8] chore: move fallback regressions into existing test suites --- .../collections/BlocksFieldAccess/index.ts | 18 + test/access-control/int.spec.ts | 83 +++ test/access-control/payload-types.ts | 22 + .../collections/FallbackValues/index.ts | 35 -- test/field-fallback/config.ts | 16 - test/field-fallback/int.spec.ts | 106 ---- test/field-fallback/payload-types.ts | 566 ------------------ test/field-fallback/shared.ts | 1 - test/field-fallback/tsconfig.json | 4 - .../collections/SelectVersions/index.ts | 1 + test/fields/int.spec.ts | 56 ++ test/fields/payload-types.ts | 10 + 12 files changed, 190 insertions(+), 728 deletions(-) delete mode 100644 test/field-fallback/collections/FallbackValues/index.ts delete mode 100644 test/field-fallback/config.ts delete mode 100644 test/field-fallback/int.spec.ts delete mode 100644 test/field-fallback/payload-types.ts delete mode 100644 test/field-fallback/shared.ts delete mode 100644 test/field-fallback/tsconfig.json diff --git a/test/access-control/collections/BlocksFieldAccess/index.ts b/test/access-control/collections/BlocksFieldAccess/index.ts index e0048e359b3..1672fd90bcb 100644 --- a/test/access-control/collections/BlocksFieldAccess/index.ts +++ b/test/access-control/collections/BlocksFieldAccess/index.ts @@ -11,6 +11,24 @@ export const BlocksFieldAccess: CollectionConfig = { update: () => true, }, fields: [ + { + name: 'rows', + type: 'array', + fields: [ + { + name: 'children', + type: 'array', + fields: [ + { + name: 'code', + type: 'text', + access: { update: () => false }, + defaultValue: 'default', + }, + ], + }, + ], + }, { name: 'title', type: 'text', diff --git a/test/access-control/int.spec.ts b/test/access-control/int.spec.ts index 78fb4c38e11..684ea62aa63 100644 --- a/test/access-control/int.spec.ts +++ b/test/access-control/int.spec.ts @@ -79,6 +79,89 @@ test.suite('Access Control', { config: './config.ts', resetBetweenTests: false } }) test.describe('Fields', () => { + test.describe('fallback values', () => { + const createdIDs: (number | string)[] = [] + + test.afterEach(async ({ payload }) => { + for (const id of createdIDs) { + await payload.delete({ id, collection: blocksFieldAccessSlug, overrideAccess: true }) + } + createdIDs.length = 0 + }) + + for (const draft of [false, true]) { + const operation = draft ? 'draft save' : 'published update' + + test(`should preserve omitted protected nested fields after a ${operation}`, async ({ + payload, + }) => { + const doc = await payload.create({ + collection: blocksFieldAccessSlug, + data: { + _status: draft ? 'draft' : 'published', + rows: [{ children: [{ code: 'stored' }] }], + title: 'original', + }, + draft, + }) + + createdIDs.push(doc.id) + + await payload.update({ + id: doc.id, + collection: blocksFieldAccessSlug, + data: { title: 'updated' }, + draft, + overrideAccess: false, + }) + + const stored = await payload.findByID({ + id: doc.id, + collection: blocksFieldAccessSlug, + draft, + }) + + expect(stored.rows?.[0]?.children?.[0]?.code).toBe('stored') + }) + + test(`should reject changes to protected nested fields during a ${operation}`, async ({ + payload, + }) => { + const doc = await payload.create({ + collection: blocksFieldAccessSlug, + data: { + _status: draft ? 'draft' : 'published', + rows: [{ children: [{ code: 'stored' }] }], + title: 'original', + }, + draft, + }) + + const row = doc.rows![0]! + + createdIDs.push(doc.id) + + await payload.update({ + id: doc.id, + collection: blocksFieldAccessSlug, + data: { + rows: [{ id: row.id, children: [{ id: row.children![0]!.id, code: 'changed' }] }], + }, + draft, + overrideAccess: false, + }) + + const stored = await payload.findByID({ + id: doc.id, + collection: blocksFieldAccessSlug, + draft, + }) + + expect(stored.rows?.[0]?.children?.[0]?.code).toBe('stored') + }) + } + }) + test('should not affect hidden fields when patching data', async ({ payload }) => { const doc = await payload.create({ collection: hiddenFieldsSlug, diff --git a/test/access-control/payload-types.ts b/test/access-control/payload-types.ts index a51995bb93b..bccbdc316a7 100644 --- a/test/access-control/payload-types.ts +++ b/test/access-control/payload-types.ts @@ -1440,6 +1440,17 @@ export interface InheritedReadVersionsVirtual { */ export interface BlocksFieldAccess { id: string; + rows?: + | { + children?: + | { + code?: string | null; + id?: string | null; + }[] + | null; + id?: string | null; + }[] + | null; title: string; editableBlocks?: TestBlock[] | null; readOnlyBlocks?: TestBlock2[] | null; @@ -2811,6 +2822,17 @@ export interface InheritedReadVersionsVirtualSelect { * via the `definition` "blocks-field-access_select". */ export interface BlocksFieldAccessSelect { + rows?: + | T + | { + children?: + | T + | { + code?: T; + id?: T; + }; + id?: T; + }; title?: T; editableBlocks?: | T diff --git a/test/field-fallback/collections/FallbackValues/index.ts b/test/field-fallback/collections/FallbackValues/index.ts deleted file mode 100644 index 6651040d4be..00000000000 --- a/test/field-fallback/collections/FallbackValues/index.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { CollectionConfig } from 'payload' - -import { fallbackValuesSlug } from '../../shared.js' - -export const FallbackValues: CollectionConfig = { - slug: fallbackValuesSlug, - access: { - create: () => true, - read: () => true, - update: () => true, - }, - fields: [ - { name: 'title', type: 'text' }, - { name: 'json', type: 'json' }, - { - name: 'rows', - type: 'array', - fields: [ - { - name: 'children', - type: 'array', - fields: [ - { - name: 'code', - type: 'text', - access: { update: () => false }, - defaultValue: 'default', - }, - ], - }, - ], - }, - ], - versions: { drafts: true }, -} diff --git a/test/field-fallback/config.ts b/test/field-fallback/config.ts deleted file mode 100644 index 7b3aca5651e..00000000000 --- a/test/field-fallback/config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' -import { FallbackValues } from './collections/FallbackValues/index.js' - -const dirname = path.dirname(fileURLToPath(import.meta.url)) - -export default buildConfigWithDefaults({ - suite: 'field-fallback', - disableMCP: true, - config: { - collections: [FallbackValues], - typescript: { outputFile: path.resolve(dirname, 'payload-types.ts') }, - }, -}) diff --git a/test/field-fallback/int.spec.ts b/test/field-fallback/int.spec.ts deleted file mode 100644 index 55837abaad3..00000000000 --- a/test/field-fallback/int.spec.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { expect } from 'vitest' - -import { test } from '../__helpers/int/vitest.js' -import { fallbackValuesSlug } from './shared.js' - -test.suite('Field fallback values', { config: './config.ts' }, () => { - for (const draft of [false, true]) { - const operation = draft ? 'draft save' : 'published update' - - test(`should preserve omitted JSON tuples after a ${operation}`, async ({ payload }) => { - const doc = await payload.create({ - collection: fallbackValuesSlug, - data: { - _status: draft ? 'draft' : 'published', - json: [ - [100, 200], - [300, 400], - ], - title: 'original', - }, - draft, - }) - - await payload.update({ - id: doc.id, - collection: fallbackValuesSlug, - data: { title: 'updated' }, - draft, - }) - - const stored = await payload.findByID({ - id: doc.id, - collection: fallbackValuesSlug, - draft, - }) - - expect(stored.json).toStrictEqual([ - [100, 200], - [300, 400], - ]) - }) - - test(`should preserve omitted protected nested fields after a ${operation}`, async ({ - payload, - }) => { - const doc = await payload.create({ - collection: fallbackValuesSlug, - data: { - _status: draft ? 'draft' : 'published', - rows: [{ children: [{ code: 'stored' }] }], - title: 'original', - }, - draft, - }) - - await payload.update({ - id: doc.id, - collection: fallbackValuesSlug, - data: { title: 'updated' }, - draft, - overrideAccess: false, - }) - - const stored = await payload.findByID({ - id: doc.id, - collection: fallbackValuesSlug, - draft, - }) - - expect(stored.rows?.[0]?.children?.[0]?.code).toBe('stored') - }) - - test(`should reject changes to protected nested fields during a ${operation}`, async ({ - payload, - }) => { - const doc = await payload.create({ - collection: fallbackValuesSlug, - data: { - _status: draft ? 'draft' : 'published', - rows: [{ children: [{ code: 'stored' }] }], - }, - draft, - }) - - const row = doc.rows![0]! - - await payload.update({ - id: doc.id, - collection: fallbackValuesSlug, - data: { - rows: [{ id: row.id, children: [{ id: row.children![0]!.id, code: 'changed' }] }], - }, - draft, - overrideAccess: false, - }) - - const stored = await payload.findByID({ - id: doc.id, - collection: fallbackValuesSlug, - draft, - }) - - expect(stored.rows?.[0]?.children?.[0]?.code).toBe('stored') - }) - } -}) diff --git a/test/field-fallback/payload-types.ts b/test/field-fallback/payload-types.ts deleted file mode 100644 index 0b0538d22da..00000000000 --- a/test/field-fallback/payload-types.ts +++ /dev/null @@ -1,566 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * This file was automatically generated by Payload. - * DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config, - * and re-run `payload generate:types` to regenerate this file. - */ - -/** - * Supported timezones in IANA format. - * - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "supportedTimezones". - */ -export type SupportedTimezones = - | 'Pacific/Midway' - | 'Pacific/Niue' - | 'Pacific/Honolulu' - | 'Pacific/Rarotonga' - | 'America/Anchorage' - | 'Pacific/Gambier' - | 'America/Los_Angeles' - | 'America/Tijuana' - | 'America/Denver' - | 'America/Phoenix' - | 'America/Chicago' - | 'America/Guatemala' - | 'America/New_York' - | 'America/Bogota' - | 'America/Caracas' - | 'America/Santiago' - | 'America/Buenos_Aires' - | 'America/Sao_Paulo' - | 'Atlantic/South_Georgia' - | 'Atlantic/Azores' - | 'Atlantic/Cape_Verde' - | 'Europe/London' - | 'Europe/Berlin' - | 'Africa/Lagos' - | 'Europe/Athens' - | 'Africa/Cairo' - | 'Europe/Moscow' - | 'Asia/Riyadh' - | 'Asia/Dubai' - | 'Asia/Baku' - | 'Asia/Karachi' - | 'Asia/Tashkent' - | 'Asia/Calcutta' - | 'Asia/Dhaka' - | 'Asia/Almaty' - | 'Asia/Jakarta' - | 'Asia/Bangkok' - | 'Asia/Shanghai' - | 'Asia/Singapore' - | 'Asia/Tokyo' - | 'Asia/Seoul' - | 'Australia/Brisbane' - | 'Australia/Sydney' - | 'Pacific/Guam' - | 'Pacific/Noumea' - | 'Pacific/Auckland' - | 'Pacific/Fiji'; -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "LexicalNodes_3DE324E4". - */ -export type LexicalNodes_3DE324E4 = - | SerializedTextNode - | SerializedTabNode - | SerializedLineBreakNode - | SerializedParagraphNode - | SerializedListNode - | SerializedListItemNode - | SerializedHeadingNode; - -export interface Config { - auth: { - users: UserAuthOperations; - }; - blocks: {}; - collections: { - 'fallback-values': FallbackValue; - 'payload-kv': PayloadKv; - users: User; - 'payload-locked-documents': PayloadLockedDocument; - 'payload-preferences': PayloadPreference; - 'payload-migrations': PayloadMigration; - 'payload-llm-instructions': PayloadLlmInstruction; - }; - collectionsJoins: {}; - collectionsSelect: { - 'fallback-values': FallbackValuesSelect | FallbackValuesSelect; - 'payload-kv': PayloadKvSelect | PayloadKvSelect; - users: UsersSelect | UsersSelect; - 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; - 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; - 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; - 'payload-llm-instructions': PayloadLlmInstructionsSelect | PayloadLlmInstructionsSelect; - }; - db: { - defaultIDType: number; - }; - fallbackLocale: null; - globals: {}; - globalsSelect: {}; - locale: null; - widgets: { - welcome: WelcomeWidget; - collections: CollectionsWidget; - 'collection-query': CollectionQueryWidget; - activity: ActivityWidget; - }; - user: User; - jobs: { - tasks: unknown; - workflows: unknown; - }; -} -export interface UserAuthOperations { - forgotPassword: { - email: string; - password: string; - }; - login: { - email: string; - password: string; - }; - registerFirstUser: { - email: string; - password: string; - }; - unlock: { - email: string; - password: string; - }; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "fallback-values". - */ -export interface FallbackValue { - id: number; - title?: string | null; - json?: - | { - [k: string]: unknown; - } - | unknown[] - | string - | number - | boolean - | null; - rows?: - | { - children?: - | { - code?: string | null; - id?: string | null; - }[] - | null; - id?: string | null; - }[] - | null; - createdBy?: { - relationTo: 'users'; - value: number | User; - } | null; - updatedBy?: { - relationTo: 'users'; - value: number | User; - } | null; - updatedAt: string; - createdAt: string; - _status?: ('draft' | 'published') | null; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "users". - */ -export interface User { - id: number; - createdBy?: { - relationTo: 'users'; - value: number | User; - } | null; - updatedBy?: { - relationTo: 'users'; - value: number | User; - } | null; - updatedAt: string; - createdAt: string; - email: string; - resetPasswordToken?: string | null; - resetPasswordExpiration?: string | null; - salt?: string | null; - hash?: string | null; - resetPasswordRequestedAt?: string | null; - loginAttempts?: number | null; - lockUntil?: string | null; - sessions?: - | { - id: string; - createdAt?: string | null; - expiresAt: string; - refreshCount?: number | null; - refreshedAt?: string | null; - }[] - | null; - password?: string | null; - collection: 'users'; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "payload-kv". - */ -export interface PayloadKv { - id: number; - key: string; - data: - | { - [k: string]: unknown; - } - | unknown[] - | string - | number - | boolean - | null; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "payload-locked-documents". - */ -export interface PayloadLockedDocument { - id: number; - document?: - | ({ - relationTo: 'fallback-values'; - value: number | FallbackValue; - } | null) - | ({ - relationTo: 'users'; - value: number | User; - } | null); - globalSlug?: string | null; - user: { - relationTo: 'users'; - value: number | User; - }; - updatedAt: string; - createdAt: string; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "payload-preferences". - */ -export interface PayloadPreference { - id: number; - user: { - relationTo: 'users'; - value: number | User; - }; - key?: string | null; - value?: - | { - [k: string]: unknown; - } - | unknown[] - | string - | number - | boolean - | null; - updatedAt: string; - createdAt: string; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "payload-migrations". - */ -export interface PayloadMigration { - id: number; - name?: string | null; - batch?: number | null; - updatedAt: string; - createdAt: string; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "payload-llm-instructions". - */ -export interface PayloadLlmInstruction { - id: string; - entitySlug: string; - title?: string | null; - entityType: 'collection' | 'global'; - additionalInstructions?: LexicalRichText | null; - systemInstructions?: LexicalRichText | null; - createdBy?: { - relationTo: 'users'; - value: number | User; - } | null; - updatedBy?: { - relationTo: 'users'; - value: number | User; - } | null; - updatedAt: string; - createdAt: string; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "fallback-values_select". - */ -export interface FallbackValuesSelect { - title?: T; - json?: T; - rows?: - | T - | { - children?: - | T - | { - code?: T; - id?: T; - }; - id?: T; - }; - createdBy?: T; - updatedBy?: T; - updatedAt?: T; - createdAt?: T; - _status?: T; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "payload-kv_select". - */ -export interface PayloadKvSelect { - key?: T; - data?: T; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "users_select". - */ -export interface UsersSelect { - createdBy?: T; - updatedBy?: T; - updatedAt?: T; - createdAt?: T; - email?: T; - resetPasswordToken?: T; - resetPasswordExpiration?: T; - salt?: T; - hash?: T; - resetPasswordRequestedAt?: T; - loginAttempts?: T; - lockUntil?: T; - sessions?: - | T - | { - id?: T; - createdAt?: T; - expiresAt?: T; - refreshCount?: T; - refreshedAt?: T; - }; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "payload-locked-documents_select". - */ -export interface PayloadLockedDocumentsSelect { - document?: T; - globalSlug?: T; - user?: T; - updatedAt?: T; - createdAt?: T; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "payload-preferences_select". - */ -export interface PayloadPreferencesSelect { - user?: T; - key?: T; - value?: T; - updatedAt?: T; - createdAt?: T; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "payload-migrations_select". - */ -export interface PayloadMigrationsSelect { - name?: T; - batch?: T; - updatedAt?: T; - createdAt?: T; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "payload-llm-instructions_select". - */ -export interface PayloadLlmInstructionsSelect { - id?: T; - entitySlug?: T; - title?: T; - entityType?: T; - additionalInstructions?: T; - systemInstructions?: T; - createdBy?: T; - updatedBy?: T; - updatedAt?: T; - createdAt?: T; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "welcome_widget". - */ -export interface WelcomeWidget { - data?: { - [k: string]: unknown; - }; - width: 'full'; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "collections_widget". - */ -export interface CollectionsWidget { - data?: { - [k: string]: unknown; - }; - width: 'full'; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "collection-query_widget". - */ -export interface CollectionQueryWidget { - data?: { - title?: string | null; - relatedCollection: 'fallback-values' | 'users'; - where?: - | { - [k: string]: unknown; - } - | unknown[] - | string - | number - | boolean - | null; - sortField?: string | null; - sortDirection?: ('asc' | 'desc') | null; - limit?: number | null; - }; - width: 'x-small' | 'small' | 'medium' | 'large' | 'x-large' | 'full'; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "activity_widget". - */ -export interface ActivityWidget { - data?: { - excludedCollections?: ('fallback-values' | 'users')[] | null; - }; - width: 'small' | 'medium' | 'large' | 'x-large' | 'full'; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "auth". - */ -export interface Auth { - [k: string]: unknown; -} - -/** @internal Core Lexical types — see @payloadcms/richtext-lexical. */ -export type LexicalElementFormat = 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; -export type LexicalElementDirection = ('ltr' | 'rtl') | null; - -export interface SerializedLexicalElementBase { - children: TChildren[]; - direction: LexicalElementDirection; - format: LexicalElementFormat; - indent: number; - textFormat?: number; - textStyle?: string; - /** @deprecated Ignored when loading. Typed as required only to match Lexical's types: rich text sent through the API, CLI or MCP may not contain it. */ - version: number; -} - -export type LexicalTextMode = 'normal' | 'token' | 'segmented'; - -export interface SerializedTextNode { - type: 'text'; - detail: number; - format: number; - mode: LexicalTextMode; - style: string; - text: string; - /** @deprecated Ignored when loading. Typed as required only to match Lexical's types: rich text sent through the API, CLI or MCP may not contain it. */ - version: number; -} - -export interface SerializedTabNode { - type: 'tab'; - detail: number; - format: number; - mode: LexicalTextMode; - style: string; - text: string; - /** @deprecated Ignored when loading. Typed as required only to match Lexical's types: rich text sent through the API, CLI or MCP may not contain it. */ - version: number; -} - -export interface SerializedLineBreakNode { - type: 'linebreak'; - /** @deprecated Ignored when loading. Typed as required only to match Lexical's types: rich text sent through the API, CLI or MCP may not contain it. */ - version: number; -} - -export interface SerializedParagraphNode extends SerializedLexicalElementBase { - type: 'paragraph'; - textFormat: number; - textStyle: string; -} - -export interface SerializedListNode extends SerializedLexicalElementBase { - type: 'list'; - checked?: boolean; - listType: 'number' | 'bullet' | 'check'; - start: number; - tag: 'ul' | 'ol'; -} - -export interface SerializedListItemNode extends SerializedLexicalElementBase { - type: 'listitem'; - checked?: boolean; - value: number; -} - -export interface SerializedHeadingNode< - TChildren, - TTag extends 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6', -> extends SerializedLexicalElementBase { - type: 'heading'; - tag: TTag; -} - -/** Shape of a Lexical `richText` field. */ -export interface LexicalRichText { - root: { - children: TNode[]; - direction: LexicalElementDirection; - format: LexicalElementFormat; - indent: number; - type: 'root'; - /** @deprecated Ignored when loading. Typed as required only to match Lexical's types: rich text sent through the API, CLI or MCP may not contain it. */ - version: number; - }; -} - - -declare module 'payload' { - // @ts-ignore - export interface GeneratedTypes extends Config {} -} diff --git a/test/field-fallback/shared.ts b/test/field-fallback/shared.ts deleted file mode 100644 index 7d7f29a8669..00000000000 --- a/test/field-fallback/shared.ts +++ /dev/null @@ -1 +0,0 @@ -export const fallbackValuesSlug = 'fallback-values' diff --git a/test/field-fallback/tsconfig.json b/test/field-fallback/tsconfig.json deleted file mode 100644 index b29da4479f9..00000000000 --- a/test/field-fallback/tsconfig.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "../tsconfig.json", - "include": ["./**/*.ts", "./**/*.tsx"] -} diff --git a/test/fields/collections/SelectVersions/index.ts b/test/fields/collections/SelectVersions/index.ts index 553440ee40c..0f2c5342815 100644 --- a/test/fields/collections/SelectVersions/index.ts +++ b/test/fields/collections/SelectVersions/index.ts @@ -6,6 +6,7 @@ const SelectVersionsFields: CollectionConfig = { slug: selectVersionsFieldsSlug, versions: { drafts: { autosave: true } }, fields: [ + { name: 'customJSON', type: 'json' }, { type: 'select', hasMany: true, diff --git a/test/fields/int.spec.ts b/test/fields/int.spec.ts index ba6981b77d2..058a928de20 100644 --- a/test/fields/int.spec.ts +++ b/test/fields/int.spec.ts @@ -35,8 +35,10 @@ import { dateFieldsSlug, duplicateFieldsSlug, groupFieldsSlug, + jsonFieldsSlug, numberFieldsSlug, relationshipFieldsSlug, + selectVersionsFieldsSlug, tabsFieldsSlug, textFieldsSlug, } from './slugs.js' @@ -5468,6 +5470,60 @@ test.suite('Fields', { config: './config.ts', resetBetweenTests: false }, () => }) test.describe('json', () => { + test.describe('fallback values', () => { + const createdDocs: { + collection: typeof jsonFieldsSlug | typeof selectVersionsFieldsSlug + id: number | string + }[] = [] + + test.afterEach(async ({ payload }) => { + for (const doc of createdDocs) { + await payload.delete({ ...doc, overrideAccess: true }) + } + createdDocs.length = 0 + }) + + for (const draft of [false, true]) { + const operation = draft ? 'draft save' : 'partial update' + const collection = draft ? selectVersionsFieldsSlug : jsonFieldsSlug + + test(`should preserve omitted JSON tuples after a ${operation}`, async ({ payload }) => { + const doc = await payload.create({ + collection, + data: { + customJSON: [ + [100, 200], + [300, 400], + ], + }, + draft, + overrideAccess: true, + }) + createdDocs.push({ collection, id: doc.id }) + + await payload.update({ + id: doc.id, + collection, + data: draft ? { hasMany: ['a'] } : { json: { foo: 'bar' } }, + draft, + overrideAccess: true, + }) + + const stored = await payload.findByID({ + id: doc.id, + collection, + draft, + overrideAccess: true, + }) + + expect(stored.customJSON).toStrictEqual([ + [100, 200], + [300, 400], + ]) + }) + } + }) + test('should save json data', async ({ payload }) => { const json = { foo: 'bar' } const doc = await payload.create({ diff --git a/test/fields/payload-types.ts b/test/fields/payload-types.ts index 0fda6a4ad0f..e9501f08e45 100644 --- a/test/fields/payload-types.ts +++ b/test/fields/payload-types.ts @@ -413,6 +413,15 @@ export interface User { */ export interface SelectVersionsField { id: string; + customJSON?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; hasMany?: ('a' | 'b' | 'c' | 'd')[] | null; array?: | { @@ -2713,6 +2722,7 @@ export interface UsersSelect { * via the `definition` "select-versions-fields_select". */ export interface SelectVersionsFieldsSelect { + customJSON?: T; hasMany?: T; array?: | T From e0c539fa74ccd64d03f4987691c3fb6efc7380e2 Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Fri, 6 Nov 2026 16:36:08 -0500 Subject: [PATCH 7/8] chore: label custom JSON field explicitly --- test/fields/collections/SelectVersions/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/fields/collections/SelectVersions/index.ts b/test/fields/collections/SelectVersions/index.ts index 0f2c5342815..915d0ffa647 100644 --- a/test/fields/collections/SelectVersions/index.ts +++ b/test/fields/collections/SelectVersions/index.ts @@ -6,7 +6,7 @@ const SelectVersionsFields: CollectionConfig = { slug: selectVersionsFieldsSlug, versions: { drafts: { autosave: true } }, fields: [ - { name: 'customJSON', type: 'json' }, + { name: 'customJSON', type: 'json', label: 'Custom JSON' }, { type: 'select', hasMany: true, From 4175880e967bcd192bb3162b1045bfbd04fbf0e7 Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Sat, 7 Nov 2026 00:53:08 -0500 Subject: [PATCH 8/8] chore: cover conditional access to nested fields --- .../collections/BlocksFieldAccess/index.ts | 7 +++- test/access-control/int.spec.ts | 37 +++++++++++++++++++ test/access-control/payload-types.ts | 4 ++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/test/access-control/collections/BlocksFieldAccess/index.ts b/test/access-control/collections/BlocksFieldAccess/index.ts index 1672fd90bcb..d79b84c2584 100644 --- a/test/access-control/collections/BlocksFieldAccess/index.ts +++ b/test/access-control/collections/BlocksFieldAccess/index.ts @@ -19,10 +19,15 @@ export const BlocksFieldAccess: CollectionConfig = { name: 'children', type: 'array', fields: [ + { + name: 'allowCodeChanges', + type: 'checkbox', + defaultValue: false, + }, { name: 'code', type: 'text', - access: { update: () => false }, + access: { update: ({ siblingData }) => siblingData?.allowCodeChanges === true }, defaultValue: 'default', }, ], diff --git a/test/access-control/int.spec.ts b/test/access-control/int.spec.ts index 684ea62aa63..6b727846c64 100644 --- a/test/access-control/int.spec.ts +++ b/test/access-control/int.spec.ts @@ -89,6 +89,43 @@ test.suite('Access Control', { config: './config.ts', resetBetweenTests: false } createdIDs.length = 0 }) + test('should allow nested field updates when sibling access permits them', async ({ + payload, + }) => { + const doc = await payload.create({ + collection: blocksFieldAccessSlug, + data: { + rows: [{ children: [{ allowCodeChanges: true, code: 'stored' }] }], + title: 'original', + }, + }) + + const row = doc.rows![0]! + + createdIDs.push(doc.id) + + await payload.update({ + id: doc.id, + collection: blocksFieldAccessSlug, + data: { + rows: [ + { + id: row.id, + children: [{ id: row.children![0]!.id, allowCodeChanges: true, code: 'changed' }], + }, + ], + }, + overrideAccess: false, + }) + + const stored = await payload.findByID({ + id: doc.id, + collection: blocksFieldAccessSlug, + }) + + expect(stored.rows?.[0]?.children?.[0]?.code).toBe('changed') + }) + for (const draft of [false, true]) { const operation = draft ? 'draft save' : 'published update' diff --git a/test/access-control/payload-types.ts b/test/access-control/payload-types.ts index bccbdc316a7..734d9a1be00 100644 --- a/test/access-control/payload-types.ts +++ b/test/access-control/payload-types.ts @@ -1444,6 +1444,7 @@ export interface BlocksFieldAccess { | { children?: | { + allowCodeChanges?: boolean | null; code?: string | null; id?: string | null; }[] @@ -1488,6 +1489,7 @@ export interface BlocksFieldAccess { } | null); updatedAt: string; createdAt: string; + _status?: ('draft' | 'published') | null; } /** * This interface was referenced by `Config`'s JSON-Schema @@ -2828,6 +2830,7 @@ export interface BlocksFieldAccessSelect { children?: | T | { + allowCodeChanges?: T; code?: T; id?: T; }; @@ -2881,6 +2884,7 @@ export interface BlocksFieldAccessSelect { updatedBy?: T; updatedAt?: T; createdAt?: T; + _status?: T; } /** * This interface was referenced by `Config`'s JSON-Schema