Describe the Bug
In collection afterChange hooks, previousDoc and doc are built with different depths, so relationship fields have different shapes on each side. previousDoc.relField is a bare ID string (or array of IDs), while doc.relField is a fully populated nested object (or array of objects). This makes them incomparable as-is and silently breaks any change-detection logic that relies on comparing them.
The asymmetry is in payload/src/collections/operations/utilities/update.ts (and the .js build):
// previousDoc — built with depth: 0 (bare IDs)
const originalDoc = await afterRead({
collection: collectionConfig,
depth: 0, // ← always 0
doc: deepCopyObjectSimple(docWithLocales),
// ...
})
// doc — built with the operation's requested depth (default 2)
result = await afterRead({
collection: collectionConfig,
depth, // ← operation's depth (default 2)
doc: result,
populate,
select,
// ...
})
// Both are then passed to afterChange hooks:
for (const hook of collectionConfig.hooks.afterChange) {
result = await hook({
doc: result, // populated
previousDoc: originalDoc, // depth 0
// ...
}) || result
}
Discussion #13120 raised the same observation in 2025 from a typing/DX angle and got no response. This issue reframes it as a correctness bug: any user code that compares previousDoc.relField to doc.relField (a perfectly natural thing to do in a change-detection hook) is silently broken — the comparison is always true for any field containing a relationship.
Why this matters in practice
A common pattern in afterChange hooks is:
afterChange: [
async ({ doc, previousDoc, operation }) => {
if (operation !== 'update' || !previousDoc) return
if (JSON.stringify(previousDoc.assignments) !== JSON.stringify(doc.assignments)) {
// expensive side effect — regenerate downstream records, fan out events, etc.
}
},
]
If assignments is an array of objects with a project relationship, this comparison always reports a change, even when the user's update touched a completely unrelated field (e.g. a flag in another group field). The expensive side effect runs on every unrelated update. In our case, the side effect was a delete-and-regenerate pass over a downstream collection, which then raced with the parent transaction and silently dropped the originating write.
Reproduction
Minimal repro: any collection with a relationship field plus an afterChange hook that compares previousDoc[field] to doc[field].
// collections/Articles.ts
import type { CollectionConfig } from 'payload'
export const Articles: CollectionConfig = {
slug: 'articles',
fields: [
{ name: 'title', type: 'text' },
{ name: 'flag', type: 'checkbox' },
{ name: 'author', type: 'relationship', relationTo: 'authors' },
],
hooks: {
afterChange: [
({ doc, previousDoc, operation }) => {
if (operation !== 'update' || !previousDoc) return
const changed =
JSON.stringify(previousDoc.author) !== JSON.stringify(doc.author)
// typeof checks confirm the asymmetry
console.log({
operation,
previousDocAuthor: typeof previousDoc.author, // 'string'
docAuthor: typeof doc.author, // 'object'
changed, // always true
})
},
],
},
}
// repro.ts
const author = await payload.create({ collection: 'authors', data: { name: 'A' } })
const article = await payload.create({
collection: 'articles',
data: { title: 'Hello', flag: false, author: author.id },
})
// Update an UNRELATED field. The hook should report `changed: false`
// (we didn't touch `author`), but it reports `changed: true`.
await payload.update({
collection: 'articles',
id: article.id,
data: { flag: true },
})
Expected console output:
{ operation: 'update', previousDocAuthor: 'string', docAuthor: 'object', changed: true }
previousDoc.author is the bare ID string "<author-id>". doc.author is { id: '<author-id>', name: 'A', ... }. These can never compare equal even when nothing changed.
Why this is a bug, not a feature request
afterChange hooks document previousDoc as "the document before the update was applied." The natural reading is that previousDoc and doc are the same shape, just at different points in time. There's no documentation warning that the two are read at different depths, and the asymmetry is deep enough that you have to read the Payload source to discover it. Any user code that takes the documented contract at face value is silently broken on relationship-containing fields.
Discussion #13120 surfaced this in July 2025 and received no engagement. Filing as a bug because:
- The behaviour silently breaks the natural use case (comparing the two)
- There is no documented workaround (users have to discover the asymmetry the hard way)
- The failure mode is silent data inconsistency, not an error
Suggested fix
Option A (preferred): build previousDoc with the same depth/populate/select as doc. Both go through afterRead already; aligning the args is a one-line change. This is the principle of least surprise.
Option B: leave the depths different but rename / strongly type / loudly document so users can't reasonably make the mistake. This is a workaround, not a fix, because every existing user codebase that relies on the natural reading of the contract still breaks.
Option C: provide a normalized comparison helper users can call (fieldsEqual(previousDoc, doc, ['field1', 'field2'])). Helpful but doesn't fix the underlying contract violation.
Related
Environment
- payload: 3.80.0
- @payloadcms/drizzle: 3.80.0
- @payloadcms/db-vercel-postgres: 3.80.0
- PostgreSQL 16 (Neon)
- Node 22
Describe the Bug
In collection
afterChangehooks,previousDocanddocare built with different depths, so relationship fields have different shapes on each side.previousDoc.relFieldis a bare ID string (or array of IDs), whiledoc.relFieldis a fully populated nested object (or array of objects). This makes them incomparable as-is and silently breaks any change-detection logic that relies on comparing them.The asymmetry is in
payload/src/collections/operations/utilities/update.ts(and the.jsbuild):Discussion #13120 raised the same observation in 2025 from a typing/DX angle and got no response. This issue reframes it as a correctness bug: any user code that compares
previousDoc.relFieldtodoc.relField(a perfectly natural thing to do in a change-detection hook) is silently broken — the comparison is always true for any field containing a relationship.Why this matters in practice
A common pattern in
afterChangehooks is:If
assignmentsis an array of objects with aprojectrelationship, this comparison always reports a change, even when the user's update touched a completely unrelated field (e.g. a flag in anothergroupfield). The expensive side effect runs on every unrelated update. In our case, the side effect was a delete-and-regenerate pass over a downstream collection, which then raced with the parent transaction and silently dropped the originating write.Reproduction
Minimal repro: any collection with a relationship field plus an
afterChangehook that comparespreviousDoc[field]todoc[field].Expected console output:
previousDoc.authoris the bare ID string"<author-id>".doc.authoris{ id: '<author-id>', name: 'A', ... }. These can never compare equal even when nothing changed.Why this is a bug, not a feature request
afterChangehooks documentpreviousDocas "the document before the update was applied." The natural reading is thatpreviousDocanddocare the same shape, just at different points in time. There's no documentation warning that the two are read at different depths, and the asymmetry is deep enough that you have to read the Payload source to discover it. Any user code that takes the documented contract at face value is silently broken on relationship-containing fields.Discussion #13120 surfaced this in July 2025 and received no engagement. Filing as a bug because:
Suggested fix
Option A (preferred): build
previousDocwith the same depth/populate/select asdoc. Both go throughafterReadalready; aligning the args is a one-line change. This is the principle of least surprise.Option B: leave the depths different but rename / strongly type / loudly document so users can't reasonably make the mistake. This is a workaround, not a fix, because every existing user codebase that relies on the natural reading of the contract still breaks.
Option C: provide a normalized comparison helper users can call (
fieldsEqual(previousDoc, doc, ['field1', 'field2'])). Helpful but doesn't fix the underlying contract violation.Related
afterChangeargpreviousValueis only set correctly for fields in root of collection #4643 — different bug in field-levelafterChangepreviousValue(sibling field name collision); not the same code pathEnvironment