fix: presentational-field types incorrectly exposing hooks (#11514)

### What?

Presentational Fields such as
[Row](https://payloadcms.com/docs/fields/row) are described as only
effecting the admin panel. If they do not impact data, their types
should not include hooks in the fields config.

### Why?

Developers can currently assign hooks to these fields, expecting them to
work, when in reality they are not called.

### How?

Omit `hooks` from `FieldBase`

Fixes #11507

---------

Co-authored-by: German Jablonski <43938777+GermanJablo@users.noreply.github.com>
This commit is contained in:
Dallas Opelt
2025-09-10 11:05:59 -07:00
committed by GitHub
parent e40c82161e
commit 285fc8cf7e
9 changed files with 39 additions and 30 deletions

View File

@@ -95,7 +95,8 @@ Here are the available Presentational Fields:
- [Collapsible](../fields/collapsible) - nests fields within a collapsible component - [Collapsible](../fields/collapsible) - nests fields within a collapsible component
- [Row](../fields/row) - aligns fields horizontally - [Row](../fields/row) - aligns fields horizontally
- [Tabs (Unnamed)](../fields/tabs) - nests fields within a tabbed layout - [Tabs (Unnamed)](../fields/tabs) - nests fields within a tabbed layout. It is not presentational if the tab has a name.
- [Group (Unnamed)](../fields/group) - nests fields within a keyed object It is not presentational if the group has a name.
- [UI](../fields/ui) - blank field for custom UI components - [UI](../fields/ui) - blank field for custom UI components
### Virtual Fields ### Virtual Fields

View File

@@ -753,7 +753,7 @@ export type NamedGroupField = {
export type UnnamedGroupField = { export type UnnamedGroupField = {
interfaceName?: never interfaceName?: never
localized?: never localized?: never
} & Omit<GroupBase, 'name' | 'virtual'> } & Omit<GroupBase, 'hooks' | 'name' | 'virtual'>
export type GroupField = NamedGroupField | UnnamedGroupField export type GroupField = NamedGroupField | UnnamedGroupField
@@ -777,7 +777,7 @@ export type RowField = {
admin?: Omit<Admin, 'description'> admin?: Omit<Admin, 'description'>
fields: Field[] fields: Field[]
type: 'row' type: 'row'
} & Omit<FieldBase, 'admin' | 'label' | 'localized' | 'name' | 'validate' | 'virtual'> } & Omit<FieldBase, 'admin' | 'hooks' | 'label' | 'localized' | 'name' | 'validate' | 'virtual'>
export type RowFieldClient = { export type RowFieldClient = {
admin?: Omit<AdminClient, 'description'> admin?: Omit<AdminClient, 'description'>
@@ -816,7 +816,7 @@ export type CollapsibleField = {
label: Required<FieldBase['label']> label: Required<FieldBase['label']>
} }
) & ) &
Omit<FieldBase, 'label' | 'localized' | 'name' | 'validate' | 'virtual'> Omit<FieldBase, 'hooks' | 'label' | 'localized' | 'name' | 'validate' | 'virtual'>
export type CollapsibleFieldClient = { export type CollapsibleFieldClient = {
admin?: { admin?: {
@@ -863,7 +863,7 @@ export type UnnamedTab = {
| LabelFunction | LabelFunction
| string | string
localized?: never localized?: never
} & Omit<TabBase, 'name' | 'virtual'> } & Omit<TabBase, 'hooks' | 'name' | 'virtual'>
export type Tab = NamedTab | UnnamedTab export type Tab = NamedTab | UnnamedTab
export type TabsField = { export type TabsField = {

View File

@@ -73,7 +73,7 @@ export const promise = async ({
if (fieldAffectsData(field)) { if (fieldAffectsData(field)) {
// Execute hooks // Execute hooks
if (field.hooks?.afterChange) { if ('hooks' in field && field.hooks?.afterChange) {
for (const hook of field.hooks.afterChange) { for (const hook of field.hooks.afterChange) {
const hookedValue = await hook({ const hookedValue = await hook({
blockData, blockData,
@@ -88,16 +88,16 @@ export const promise = async ({
path: pathSegments, path: pathSegments,
previousDoc, previousDoc,
previousSiblingDoc, previousSiblingDoc,
previousValue: previousDoc?.[field.name!], previousValue: previousDoc?.[field.name],
req, req,
schemaPath: schemaPathSegments, schemaPath: schemaPathSegments,
siblingData, siblingData,
siblingFields: siblingFields!, siblingFields: siblingFields!,
value: siblingDoc?.[field.name!], value: siblingDoc?.[field.name],
}) })
if (hookedValue !== undefined) { if (hookedValue !== undefined) {
siblingDoc[field.name!] = hookedValue siblingDoc[field.name] = hookedValue
} }
} }
} }

View File

@@ -238,15 +238,15 @@ export const promise = async ({
if (fieldAffectsDataResult) { if (fieldAffectsDataResult) {
// Execute hooks // Execute hooks
if (triggerHooks && field.hooks?.afterRead) { if (triggerHooks && 'hooks' in field && field.hooks?.afterRead) {
for (const hook of field.hooks.afterRead) { for (const hook of field.hooks.afterRead) {
const shouldRunHookOnAllLocales = const shouldRunHookOnAllLocales =
fieldShouldBeLocalized({ field, parentIsLocalized: parentIsLocalized! }) && fieldShouldBeLocalized({ field, parentIsLocalized: parentIsLocalized! }) &&
(locale === 'all' || !flattenLocales) && (locale === 'all' || !flattenLocales) &&
typeof siblingDoc[field.name!] === 'object' typeof siblingDoc[field.name] === 'object'
if (shouldRunHookOnAllLocales) { if (shouldRunHookOnAllLocales) {
const localesAndValues = Object.entries(siblingDoc[field.name!]) const localesAndValues = Object.entries(siblingDoc[field.name])
await Promise.all( await Promise.all(
localesAndValues.map(async ([localeKey, value]) => { localesAndValues.map(async ([localeKey, value]) => {
const hookedValue = await hook({ const hookedValue = await hook({
@@ -274,7 +274,7 @@ export const promise = async ({
}) })
if (hookedValue !== undefined) { if (hookedValue !== undefined) {
siblingDoc[field.name!][localeKey] = hookedValue siblingDoc[field.name][localeKey] = hookedValue
} }
}), }),
) )
@@ -300,11 +300,11 @@ export const promise = async ({
showHiddenFields, showHiddenFields,
siblingData: siblingDoc, siblingData: siblingDoc,
siblingFields: siblingFields!, siblingFields: siblingFields!,
value: siblingDoc[field.name!], value: siblingDoc[field.name],
}) })
if (hookedValue !== undefined) { if (hookedValue !== undefined) {
siblingDoc[field.name!] = hookedValue siblingDoc[field.name] = hookedValue
} }
} }
} }

View File

@@ -129,7 +129,7 @@ export const promise = async ({
} }
// Execute hooks // Execute hooks
if (field.hooks?.beforeChange) { if ('hooks' in field && field.hooks?.beforeChange) {
for (const hook of field.hooks.beforeChange) { for (const hook of field.hooks.beforeChange) {
const hookedValue = await hook({ const hookedValue = await hook({
blockData, blockData,
@@ -143,17 +143,17 @@ export const promise = async ({
originalDoc: doc, originalDoc: doc,
path: pathSegments, path: pathSegments,
previousSiblingDoc: siblingDoc, previousSiblingDoc: siblingDoc,
previousValue: siblingDoc[field.name!], previousValue: siblingDoc[field.name],
req, req,
schemaPath: schemaPathSegments, schemaPath: schemaPathSegments,
siblingData, siblingData,
siblingDocWithLocales, siblingDocWithLocales,
siblingFields: siblingFields!, siblingFields: siblingFields!,
value: siblingData[field.name!], value: siblingData[field.name],
}) })
if (hookedValue !== undefined) { if (hookedValue !== undefined) {
siblingData[field.name!] = hookedValue siblingData[field.name] = hookedValue
} }
} }
} }

View File

@@ -65,7 +65,7 @@ export const promise = async <T>({
// Run field beforeDuplicate hooks. // Run field beforeDuplicate hooks.
// These hooks are responsible for resetting the `id` field values of array and block rows. See `baseIDField`. // These hooks are responsible for resetting the `id` field values of array and block rows. See `baseIDField`.
if (Array.isArray(field.hooks?.beforeDuplicate)) { if (Array.isArray('hooks' in field && field.hooks?.beforeDuplicate)) {
if (fieldIsLocalized) { if (fieldIsLocalized) {
const localeData: JsonObject = {} const localeData: JsonObject = {}
@@ -90,9 +90,11 @@ export const promise = async <T>({
} }
let hookResult let hookResult
if ('hooks' in field && field.hooks?.beforeDuplicate) {
for (const hook of field.hooks.beforeDuplicate) { for (const hook of field.hooks.beforeDuplicate) {
hookResult = await hook(beforeDuplicateArgs) hookResult = await hook(beforeDuplicateArgs)
} }
}
if (typeof hookResult !== 'undefined') { if (typeof hookResult !== 'undefined') {
localeData[locale] = hookResult localeData[locale] = hookResult
@@ -121,9 +123,11 @@ export const promise = async <T>({
} }
let hookResult let hookResult
if ('hooks' in field && field.hooks?.beforeDuplicate) {
for (const hook of field.hooks.beforeDuplicate) { for (const hook of field.hooks.beforeDuplicate) {
hookResult = await hook(beforeDuplicateArgs) hookResult = await hook(beforeDuplicateArgs)
} }
}
if (typeof hookResult !== 'undefined') { if (typeof hookResult !== 'undefined') {
siblingDoc[field.name!] = hookResult siblingDoc[field.name!] = hookResult

View File

@@ -284,7 +284,7 @@ export const promise = async <T>({
} }
// Execute hooks // Execute hooks
if (field.hooks?.beforeValidate) { if ('hooks' in field && field.hooks?.beforeValidate) {
for (const hook of field.hooks.beforeValidate) { for (const hook of field.hooks.beforeValidate) {
const hookedValue = await hook({ const hookedValue = await hook({
blockData, blockData,
@@ -299,19 +299,19 @@ export const promise = async <T>({
overrideAccess, overrideAccess,
path: pathSegments, path: pathSegments,
previousSiblingDoc: siblingDoc, previousSiblingDoc: siblingDoc,
previousValue: siblingDoc[field.name!], previousValue: siblingDoc[field.name],
req, req,
schemaPath: schemaPathSegments, schemaPath: schemaPathSegments,
siblingData, siblingData,
siblingFields: siblingFields!, siblingFields: siblingFields!,
value: value:
typeof siblingData[field.name!] === 'undefined' typeof siblingData[field.name] === 'undefined'
? fallbackResult.value ? fallbackResult.value
: siblingData[field.name!], : siblingData[field.name],
}) })
if (hookedValue !== undefined) { if (hookedValue !== undefined) {
siblingData[field.name!] = hookedValue siblingData[field.name] = hookedValue
} }
} }
} }

View File

@@ -13,6 +13,7 @@ export const setDefaultBeforeDuplicate = (
) => { ) => {
if ( if (
(('required' in field && field.required) || field.unique) && (('required' in field && field.required) || field.unique) &&
'hooks' in field &&
(!field.hooks?.beforeDuplicate || (!field.hooks?.beforeDuplicate ||
(Array.isArray(field.hooks.beforeDuplicate) && field.hooks.beforeDuplicate.length === 0)) (Array.isArray(field.hooks.beforeDuplicate) && field.hooks.beforeDuplicate.length === 0))
) { ) {

View File

@@ -119,7 +119,10 @@ export const getFields = ({
generateFileURL, generateFileURL,
size, size,
}), }),
...(existingSizeURLField?.hooks?.afterRead || []), ...((typeof existingSizeURLField === 'object' &&
'hooks' in existingSizeURLField &&
existingSizeURLField?.hooks?.afterRead) ||
[]),
], ],
}, },
}, },