From cd6926f5befbed3f82ef172ff0a20ca679736d5f Mon Sep 17 00:00:00 2001 From: Jessica Chowdhury Date: Mon, 8 Dec 2025 15:39:31 +0000 Subject: [PATCH 1/5] fix: previousValue from hooks should be populated within lexical blocks --- .../src/fields/hooks/afterChange/promise.ts | 7 +++- .../NestedAfterChangeHook/index.ts | 34 +++++++++++++++++++ test/hooks/int.spec.ts | 32 +++++++++++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/packages/payload/src/fields/hooks/afterChange/promise.ts b/packages/payload/src/fields/hooks/afterChange/promise.ts index dd67284b87a..a199a0ac85f 100644 --- a/packages/payload/src/fields/hooks/afterChange/promise.ts +++ b/packages/payload/src/fields/hooks/afterChange/promise.ts @@ -72,6 +72,10 @@ export const promise = async ({ const indexPathSegments = indexPath ? indexPath.split('-').filter(Boolean)?.map(Number) : [] const getNestedValue = (data: JsonObject, path: string[]) => path.reduce((acc, key) => (acc && acc[key] !== undefined ? acc[key] : undefined), data) + const previousValData = + previousSiblingDoc && Object.keys(previousSiblingDoc).length > 0 + ? previousSiblingDoc + : previousDoc if (fieldAffectsData(field)) { // Execute hooks @@ -90,7 +94,8 @@ export const promise = async ({ path: pathSegments, previousDoc, previousSiblingDoc, - previousValue: getNestedValue(previousDoc, pathSegments) ?? previousDoc?.[field.name], + previousValue: + getNestedValue(previousValData, pathSegments) ?? previousValData?.[field.name], req, schemaPath: schemaPathSegments, siblingData, diff --git a/test/hooks/collections/NestedAfterChangeHook/index.ts b/test/hooks/collections/NestedAfterChangeHook/index.ts index b61e8f7b11b..ced21b92a0f 100644 --- a/test/hooks/collections/NestedAfterChangeHook/index.ts +++ b/test/hooks/collections/NestedAfterChangeHook/index.ts @@ -1,4 +1,6 @@ import type { CollectionConfig } from 'payload' + +import { BlocksFeature, lexicalEditor } from '@payloadcms/richtext-lexical' export const nestedAfterChangeHooksSlug = 'nested-after-change-hooks' const NestedAfterChangeHooks: CollectionConfig = { @@ -34,6 +36,38 @@ const NestedAfterChangeHooks: CollectionConfig = { }, ], }, + { + name: 'lexical', + type: 'richText', + editor: lexicalEditor({ + features: ({ defaultFeatures }) => [ + ...defaultFeatures, + BlocksFeature({ + blocks: [ + { + slug: 'nestedBlock', + fields: [ + { + type: 'text', + name: 'nestedAfterChange', + hooks: { + afterChange: [ + ({ previousValue, operation }) => { + console.log(previousValue) + if (operation === 'update' && typeof previousValue === 'undefined') { + throw new Error('previousValue is missing in nested beforeChange hook') + } + }, + ], + }, + }, + ], + }, + ], + }), + ], + }), + }, ], } diff --git a/test/hooks/int.spec.ts b/test/hooks/int.spec.ts index cced505a1fc..ee452f539d3 100644 --- a/test/hooks/int.spec.ts +++ b/test/hooks/int.spec.ts @@ -343,6 +343,38 @@ describe('Hooks', () => { }, ], }, + lexical: { + root: { + children: [ + { + type: 'block', + version: 2, + format: '', + fields: { + id: '6936efd83bd1813d170dc585', + blockName: '', + nestedAfterChange: 'a', + blockType: 'nestedBlock', + }, + }, + { + children: [], + direction: null, + format: '', + indent: 0, + type: 'paragraph', + version: 1, + textFormat: 0, + textStyle: '', + }, + ], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1, + }, + }, }, }) From b666a85e61d1ee5da35fe9e30a2bd5501b9559dc Mon Sep 17 00:00:00 2001 From: Jessica Chowdhury Date: Wed, 10 Dec 2025 16:09:32 +0000 Subject: [PATCH 2/5] chore: add test for lexical link feat --- .../src/fields/hooks/afterChange/promise.ts | 2 +- .../NestedAfterChangeHook/index.ts | 34 +++++- test/hooks/int.spec.ts | 83 ++++++++++++-- test/hooks/payload-types.ts | 101 ++++++++++-------- 4 files changed, 165 insertions(+), 55 deletions(-) diff --git a/packages/payload/src/fields/hooks/afterChange/promise.ts b/packages/payload/src/fields/hooks/afterChange/promise.ts index a199a0ac85f..e38d17dc57e 100644 --- a/packages/payload/src/fields/hooks/afterChange/promise.ts +++ b/packages/payload/src/fields/hooks/afterChange/promise.ts @@ -177,7 +177,7 @@ export const promise = async ({ parentPath: path + '.' + rowIndex, parentSchemaPath: schemaPath + '.' + block.slug, previousDoc, - previousSiblingDoc: previousDoc?.[field.name]?.[rowIndex] || ({} as JsonObject), + previousSiblingDoc: previousValData?.[field.name]?.[rowIndex] || ({} as JsonObject), req, siblingData: siblingData?.[field.name]?.[rowIndex] || {}, siblingDoc: row ? { ...row } : {}, diff --git a/test/hooks/collections/NestedAfterChangeHook/index.ts b/test/hooks/collections/NestedAfterChangeHook/index.ts index ced21b92a0f..6c917f9ddf0 100644 --- a/test/hooks/collections/NestedAfterChangeHook/index.ts +++ b/test/hooks/collections/NestedAfterChangeHook/index.ts @@ -1,6 +1,6 @@ import type { CollectionConfig } from 'payload' -import { BlocksFeature, lexicalEditor } from '@payloadcms/richtext-lexical' +import { BlocksFeature, lexicalEditor, LinkFeature } from '@payloadcms/richtext-lexical' export const nestedAfterChangeHooksSlug = 'nested-after-change-hooks' const NestedAfterChangeHooks: CollectionConfig = { @@ -65,6 +65,38 @@ const NestedAfterChangeHooks: CollectionConfig = { }, ], }), + LinkFeature({ + fields: [ + { + type: 'blocks', + name: 'linkBlocks', + blocks: [ + { + slug: 'nestedLinkBlock', + fields: [ + { + name: 'nestedRelationship', + type: 'relationship', + relationTo: 'relations', + hooks: { + afterChange: [ + ({ previousValue, operation }) => { + console.log(previousValue) + if (operation === 'update' && typeof previousValue === 'undefined') { + throw new Error( + 'previousValue is missing in nested beforeChange hook', + ) + } + }, + ], + }, + }, + ], + }, + ], + }, + ], + }), ], }), }, diff --git a/test/hooks/int.spec.ts b/test/hooks/int.spec.ts index ee452f539d3..a7929c62c5d 100644 --- a/test/hooks/int.spec.ts +++ b/test/hooks/int.spec.ts @@ -331,6 +331,47 @@ describe('Hooks', () => { }) it('should populate previousValue in nested afterChange hooks', async () => { + // this collection will throw an error if previousValue is not defined in nested afterChange hook + const nestedAfterChangeDoc = await payload.create({ + collection: nestedAfterChangeHooksSlug, + data: { + text: 'initial', + group: { + array: [ + { + nestedAfterChange: 'initial', + }, + ], + }, + }, + }) + + const updatedDoc = await payload.update({ + collection: 'nested-after-change-hooks', + id: nestedAfterChangeDoc.id, + data: { + text: 'updated', + group: { + array: [ + { + nestedAfterChange: 'updated', + }, + ], + }, + }, + }) + + expect(updatedDoc).toBeDefined() + }) + + it('should populate previousValue in Lexical nested afterChange hooks', async () => { + const relationID = await payload.create({ + collection: 'relations', + data: { + title: 'Relation for nested afterChange', + }, + }) + // this collection will throw an error if previousValue is not defined in nested afterChange hook const nestedAfterChangeDoc = await payload.create({ collection: nestedAfterChangeHooksSlug, @@ -351,14 +392,41 @@ describe('Hooks', () => { version: 2, format: '', fields: { - id: '6936efd83bd1813d170dc585', + id: '693994702cb0a2476e9bdddd', blockName: '', - nestedAfterChange: 'a', + nestedAfterChange: 'initial block', blockType: 'nestedBlock', }, }, { - children: [], + children: [ + { + children: [ + { + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: 'test', + type: 'text', + version: 1, + }, + ], + direction: null, + format: '', + indent: 0, + type: 'link', + version: 3, + fields: { + linkBlocks: [ + { + blockType: 'nestedLinkBlock', + nestedRelationship: relationID.id, + }, + ], + }, + }, + ], direction: null, format: '', indent: 0, @@ -383,16 +451,9 @@ describe('Hooks', () => { id: nestedAfterChangeDoc.id, data: { text: 'updated', - group: { - array: [ - { - nestedAfterChange: 'updated', - }, - ], - }, }, }) - + console.log(updatedDoc) expect(updatedDoc).toBeDefined() }) }) diff --git a/test/hooks/payload-types.ts b/test/hooks/payload-types.ts index ce47fd274f7..c3d4876fdf6 100644 --- a/test/hooks/payload-types.ts +++ b/test/hooks/payload-types.ts @@ -112,8 +112,9 @@ export interface Config { 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; }; db: { - defaultIDType: number; + defaultIDType: string; }; + fallbackLocale: null; globals: { 'data-hooks-global': DataHooksGlobal; }; @@ -152,7 +153,7 @@ export interface HooksUserAuthOperations { * via the `definition` "before-change-hooks". */ export interface BeforeChangeHook { - id: number; + id: string; title: string; updatedAt: string; createdAt: string; @@ -162,7 +163,7 @@ export interface BeforeChangeHook { * via the `definition` "before-validate". */ export interface BeforeValidate { - id: number; + id: string; title?: string | null; selection?: ('a' | 'b') | null; updatedAt: string; @@ -173,7 +174,7 @@ export interface BeforeValidate { * via the `definition` "afterOperation". */ export interface AfterOperation { - id: number; + id: string; title: string; updatedAt: string; createdAt: string; @@ -183,7 +184,7 @@ export interface AfterOperation { * via the `definition` "context-hooks". */ export interface ContextHook { - id: number; + id: string; value?: string | null; updatedAt: string; createdAt: string; @@ -193,7 +194,7 @@ export interface ContextHook { * via the `definition` "transforms". */ export interface Transform { - id: number; + id: string; /** * @minItems 2 * @maxItems 2 @@ -212,7 +213,7 @@ export interface Transform { * via the `definition` "hooks". */ export interface Hook { - id: number; + id: string; fieldBeforeValidate?: boolean | null; fieldBeforeChange?: boolean | null; fieldAfterChange?: boolean | null; @@ -230,20 +231,20 @@ export interface Hook { * via the `definition` "nested-after-read-hooks". */ export interface NestedAfterReadHook { - id: number; + id: string; text?: string | null; group?: { array?: | { input?: string | null; afterRead?: string | null; - shouldPopulate?: (number | null) | Relation; + shouldPopulate?: (string | null) | Relation; id?: string | null; }[] | null; subGroup?: { afterRead?: string | null; - shouldPopulate?: (number | null) | Relation; + shouldPopulate?: (string | null) | Relation; }; }; updatedAt: string; @@ -254,7 +255,7 @@ export interface NestedAfterReadHook { * via the `definition` "relations". */ export interface Relation { - id: number; + id: string; title: string; updatedAt: string; createdAt: string; @@ -264,7 +265,7 @@ export interface Relation { * via the `definition` "nested-after-change-hooks". */ export interface NestedAfterChangeHook { - id: number; + id: string; text?: string | null; group?: { array?: @@ -274,6 +275,21 @@ export interface NestedAfterChangeHook { }[] | null; }; + lexical?: { + root: { + type: string; + children: { + type: any; + version: number; + [k: string]: unknown; + }[]; + direction: ('ltr' | 'rtl') | null; + format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; + indent: number; + version: number; + }; + [k: string]: unknown; + } | null; updatedAt: string; createdAt: string; } @@ -282,7 +298,7 @@ export interface NestedAfterChangeHook { * via the `definition` "chaining-hooks". */ export interface ChainingHook { - id: number; + id: string; text?: string | null; updatedAt: string; createdAt: string; @@ -292,7 +308,7 @@ export interface ChainingHook { * via the `definition` "hooks-users". */ export interface HooksUser { - id: number; + id: string; roles: ('admin' | 'user')[]; afterLoginHook?: boolean | null; updatedAt: string; @@ -318,7 +334,7 @@ export interface HooksUser { * via the `definition` "data-hooks". */ export interface DataHook { - id: number; + id: string; field_collectionAndField?: string | null; collection_beforeOperation_collection?: string | null; collection_beforeChange_collection?: string | null; @@ -334,7 +350,7 @@ export interface DataHook { * via the `definition` "before-delete-hooks". */ export interface BeforeDeleteHook { - id: number; + id: string; title?: string | null; updatedAt: string; createdAt: string; @@ -344,7 +360,7 @@ export interface BeforeDeleteHook { * via the `definition` "before-delete-2-hooks". */ export interface BeforeDelete2Hook { - id: number; + id: string; title?: string | null; updatedAt: string; createdAt: string; @@ -354,7 +370,7 @@ export interface BeforeDelete2Hook { * via the `definition` "field-paths". */ export interface FieldPath { - id: number; + id: string; topLevelNamedField?: string | null; array?: | { @@ -671,7 +687,7 @@ export interface FieldPath { * via the `definition` "value-hooks". */ export interface ValueHook { - id: number; + id: string; slug?: string | null; beforeValidate_value?: string | null; beforeChange_value?: string | null; @@ -683,7 +699,7 @@ export interface ValueHook { * via the `definition` "payload-kv". */ export interface PayloadKv { - id: number; + id: string; key: string; data: | { @@ -700,76 +716,76 @@ export interface PayloadKv { * via the `definition` "payload-locked-documents". */ export interface PayloadLockedDocument { - id: number; + id: string; document?: | ({ relationTo: 'before-change-hooks'; - value: number | BeforeChangeHook; + value: string | BeforeChangeHook; } | null) | ({ relationTo: 'before-validate'; - value: number | BeforeValidate; + value: string | BeforeValidate; } | null) | ({ relationTo: 'afterOperation'; - value: number | AfterOperation; + value: string | AfterOperation; } | null) | ({ relationTo: 'context-hooks'; - value: number | ContextHook; + value: string | ContextHook; } | null) | ({ relationTo: 'transforms'; - value: number | Transform; + value: string | Transform; } | null) | ({ relationTo: 'hooks'; - value: number | Hook; + value: string | Hook; } | null) | ({ relationTo: 'nested-after-read-hooks'; - value: number | NestedAfterReadHook; + value: string | NestedAfterReadHook; } | null) | ({ relationTo: 'nested-after-change-hooks'; - value: number | NestedAfterChangeHook; + value: string | NestedAfterChangeHook; } | null) | ({ relationTo: 'chaining-hooks'; - value: number | ChainingHook; + value: string | ChainingHook; } | null) | ({ relationTo: 'relations'; - value: number | Relation; + value: string | Relation; } | null) | ({ relationTo: 'hooks-users'; - value: number | HooksUser; + value: string | HooksUser; } | null) | ({ relationTo: 'data-hooks'; - value: number | DataHook; + value: string | DataHook; } | null) | ({ relationTo: 'before-delete-hooks'; - value: number | BeforeDeleteHook; + value: string | BeforeDeleteHook; } | null) | ({ relationTo: 'before-delete-2-hooks'; - value: number | BeforeDelete2Hook; + value: string | BeforeDelete2Hook; } | null) | ({ relationTo: 'field-paths'; - value: number | FieldPath; + value: string | FieldPath; } | null) | ({ relationTo: 'value-hooks'; - value: number | ValueHook; + value: string | ValueHook; } | null); globalSlug?: string | null; user: { relationTo: 'hooks-users'; - value: number | HooksUser; + value: string | HooksUser; }; updatedAt: string; createdAt: string; @@ -779,10 +795,10 @@ export interface PayloadLockedDocument { * via the `definition` "payload-preferences". */ export interface PayloadPreference { - id: number; + id: string; user: { relationTo: 'hooks-users'; - value: number | HooksUser; + value: string | HooksUser; }; key?: string | null; value?: @@ -802,7 +818,7 @@ export interface PayloadPreference { * via the `definition` "payload-migrations". */ export interface PayloadMigration { - id: number; + id: string; name?: string | null; batch?: number | null; updatedAt: string; @@ -915,6 +931,7 @@ export interface NestedAfterChangeHooksSelect { id?: T; }; }; + lexical?: T; updatedAt?: T; createdAt?: T; } @@ -1111,7 +1128,7 @@ export interface PayloadMigrationsSelect { * via the `definition` "data-hooks-global". */ export interface DataHooksGlobal { - id: number; + id: string; field_globalAndField?: string | null; global_beforeChange_global?: string | null; global_afterChange_global?: string | null; From b76a2f914f30bfe98430a707a24d926cb618b2f1 Mon Sep 17 00:00:00 2001 From: German Jablonski <43938777+GermanJablo@users.noreply.github.com> Date: Thu, 11 Dec 2025 14:30:27 +0000 Subject: [PATCH 3/5] test: update integration test to assert no errors on document update --- test/hooks/int.spec.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/test/hooks/int.spec.ts b/test/hooks/int.spec.ts index a7929c62c5d..4318e011479 100644 --- a/test/hooks/int.spec.ts +++ b/test/hooks/int.spec.ts @@ -446,15 +446,15 @@ describe('Hooks', () => { }, }) - const updatedDoc = await payload.update({ - collection: 'nested-after-change-hooks', - id: nestedAfterChangeDoc.id, - data: { - text: 'updated', - }, - }) - console.log(updatedDoc) - expect(updatedDoc).toBeDefined() + await expect( + payload.update({ + collection: 'nested-after-change-hooks', + id: nestedAfterChangeDoc.id, + data: { + text: 'updated', + }, + }), + ).resolves.not.toThrow() }) }) From d30bff7396906ec1903825115c33725671a891b0 Mon Sep 17 00:00:00 2001 From: Jessica Chowdhury Date: Thu, 11 Dec 2025 15:19:32 +0000 Subject: [PATCH 4/5] chore: fix test --- test/hooks/int.spec.ts | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/test/hooks/int.spec.ts b/test/hooks/int.spec.ts index 6af09d860ca..122bfc8bbc9 100644 --- a/test/hooks/int.spec.ts +++ b/test/hooks/int.spec.ts @@ -392,17 +392,6 @@ describe('Hooks', () => { lexical: { root: { children: [ - { - type: 'block', - version: 2, - format: '', - fields: { - id: '693994702cb0a2476e9bdddd', - blockName: '', - nestedAfterChange: 'initial block', - blockType: 'nestedBlock', - }, - }, { children: [ { @@ -412,7 +401,7 @@ describe('Hooks', () => { format: 0, mode: 'normal', style: '', - text: 'test', + text: 'link', type: 'text', version: 1, }, @@ -425,11 +414,13 @@ describe('Hooks', () => { fields: { linkBlocks: [ { + id: '693ade72068ea07ba13edcab', blockType: 'nestedLinkBlock', nestedRelationship: relationID.id, }, ], }, + id: '693ade70068ea07ba13edca9', }, ], direction: null, @@ -440,6 +431,27 @@ describe('Hooks', () => { textFormat: 0, textStyle: '', }, + { + type: 'block', + version: 2, + format: '', + fields: { + id: '693adf3c068ea07ba13edcae', + blockName: '', + nestedAfterChange: 'test', + blockType: 'nestedBlock', + }, + }, + { + children: [], + direction: null, + format: '', + indent: 0, + type: 'paragraph', + version: 1, + textFormat: 0, + textStyle: '', + }, ], direction: null, format: '', From c802de34e3c576f48388e55587d7decb2887e7d4 Mon Sep 17 00:00:00 2001 From: German Jablonski <43938777+GermanJablo@users.noreply.github.com> Date: Thu, 11 Dec 2025 15:26:56 +0000 Subject: [PATCH 5/5] remove console.logs --- test/hooks/collections/NestedAfterChangeHook/index.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/test/hooks/collections/NestedAfterChangeHook/index.ts b/test/hooks/collections/NestedAfterChangeHook/index.ts index 6c917f9ddf0..b06d27e9867 100644 --- a/test/hooks/collections/NestedAfterChangeHook/index.ts +++ b/test/hooks/collections/NestedAfterChangeHook/index.ts @@ -24,7 +24,6 @@ const NestedAfterChangeHooks: CollectionConfig = { hooks: { afterChange: [ ({ previousValue, operation }) => { - console.log(previousValue) if (operation === 'update' && typeof previousValue === 'undefined') { throw new Error('previousValue is missing in nested beforeChange hook') } @@ -53,7 +52,6 @@ const NestedAfterChangeHooks: CollectionConfig = { hooks: { afterChange: [ ({ previousValue, operation }) => { - console.log(previousValue) if (operation === 'update' && typeof previousValue === 'undefined') { throw new Error('previousValue is missing in nested beforeChange hook') } @@ -81,7 +79,6 @@ const NestedAfterChangeHooks: CollectionConfig = { hooks: { afterChange: [ ({ previousValue, operation }) => { - console.log(previousValue) if (operation === 'update' && typeof previousValue === 'undefined') { throw new Error( 'previousValue is missing in nested beforeChange hook',