From 6359f6d38f810e64f0a26b6d1676c9cb2ff98b32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Linus=20R=C3=B6nnb=C3=A4ck?= Date: Mon, 23 Mar 2026 09:40:29 +0100 Subject: [PATCH] fix(plugin-nested-docs): await populateBreadcrumbs in resaveChildren hook --- .../src/hooks/resaveChildren.spec.ts | 92 +++++++++++++++++++ .../src/hooks/resaveChildren.ts | 2 +- test/plugin-nested-docs/int.spec.ts | 90 ++++++++++++++++++ 3 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 packages/plugin-nested-docs/src/hooks/resaveChildren.spec.ts diff --git a/packages/plugin-nested-docs/src/hooks/resaveChildren.spec.ts b/packages/plugin-nested-docs/src/hooks/resaveChildren.spec.ts new file mode 100644 index 00000000000..3bb05c12f50 --- /dev/null +++ b/packages/plugin-nested-docs/src/hooks/resaveChildren.spec.ts @@ -0,0 +1,92 @@ +import type { CollectionAfterChangeHook, JsonObject, SanitizedCollectionConfig } from 'payload' + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../utilities/populateBreadcrumbs.js', () => ({ + populateBreadcrumbs: vi.fn(), +})) + +import type { NestedDocsPluginConfig } from '../types.js' + +import { populateBreadcrumbs } from '../utilities/populateBreadcrumbs.js' +import { resaveChildren } from './resaveChildren.js' + +const mockPopulateBreadcrumbs = vi.mocked(populateBreadcrumbs) + +describe('resaveChildren', () => { + const mockUpdate = vi.fn() + const mockFind = vi.fn() + const mockLogger = { error: vi.fn() } + + const collection = { + slug: 'pages', + versions: { drafts: true }, + } as unknown as SanitizedCollectionConfig + + const pluginConfig: NestedDocsPluginConfig = { + collections: ['pages'], + generateLabel: (_, doc) => doc.title as string, + generateURL: (docs) => docs.reduce((url, doc) => `${url}/${doc.slug}`, ''), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should await populateBreadcrumbs before passing data to payload.update', async () => { + const resolvedData = { + id: 'child-1', + title: 'Child', + slug: 'child', + parent: 'parent-1', + _status: 'published', + breadcrumbs: [ + { doc: 'parent-1', label: 'Parent', url: '/parent' }, + { doc: 'child-1', label: 'Child', url: '/parent/child' }, + ], + } + + mockPopulateBreadcrumbs.mockResolvedValue(resolvedData) + + const child: JsonObject = { + id: 'child-1', + title: 'Child', + slug: 'child', + parent: 'parent-1', + _status: 'published', + updatedAt: '2026-01-01', + } + + mockFind + .mockResolvedValueOnce({ docs: [] }) // draft children query + .mockResolvedValueOnce({ docs: [child] }) // published children query + + const hook = resaveChildren(pluginConfig) as CollectionAfterChangeHook + + await hook({ + collection, + context: {}, + doc: { id: 'parent-1', _status: 'published' }, + operation: 'update', + previousDoc: {}, + req: { + locale: 'en', + payload: { + find: mockFind, + logger: mockLogger, + update: mockUpdate, + }, + } as any, + }) + + expect(mockUpdate).toHaveBeenCalledTimes(1) + + const updateArgs = mockUpdate.mock.calls[0][0] + + // The critical assertion: data must be the resolved object, not a Promise + expect(updateArgs.data).toBe(resolvedData) + expect(updateArgs.data).not.toBeInstanceOf(Promise) + expect(updateArgs.data.breadcrumbs).toHaveLength(2) + expect(updateArgs.data.breadcrumbs[0].label).toBe('Parent') + }) +}) diff --git a/packages/plugin-nested-docs/src/hooks/resaveChildren.ts b/packages/plugin-nested-docs/src/hooks/resaveChildren.ts index 5a7eac66399..b0df06bdebf 100644 --- a/packages/plugin-nested-docs/src/hooks/resaveChildren.ts +++ b/packages/plugin-nested-docs/src/hooks/resaveChildren.ts @@ -71,7 +71,7 @@ export const resaveChildren = await req.payload.update({ id: child.id, collection: collection.slug, - data: populateBreadcrumbs({ + data: await populateBreadcrumbs({ collection, data: child, generateLabel: pluginConfig.generateLabel, diff --git a/test/plugin-nested-docs/int.spec.ts b/test/plugin-nested-docs/int.spec.ts index 9dd8a18303e..cc75643dc05 100644 --- a/test/plugin-nested-docs/int.spec.ts +++ b/test/plugin-nested-docs/int.spec.ts @@ -192,6 +192,96 @@ describe('@payloadcms/plugin-nested-docs', () => { }) }) + describe('resaveChildren', () => { + it('should keep parent published after resaving children breadcrumbs', async () => { + const parentDoc = await payload.create({ + collection: 'pages', + data: { + title: 'publish-parent', + slug: 'publish-parent', + _status: 'published', + }, + }) + + await payload.create({ + collection: 'pages', + data: { + title: 'publish-child', + slug: 'publish-child', + parent: parentDoc.id, + _status: 'published', + }, + }) + + // Re-publish the parent with an updated slug — this triggers resaveChildren + const updatedParent = await payload.update({ + collection: 'pages', + id: parentDoc.id, + data: { + title: 'publish-parent-updated', + slug: 'publish-parent-updated', + _status: 'published', + }, + }) + + // Re-read the parent to confirm the transaction was not rolled back + const refetchedParent = await payload.findByID({ + collection: 'pages', + id: parentDoc.id, + }) + + expect(updatedParent._status).toStrictEqual('published') + expect(refetchedParent._status).toStrictEqual('published') + expect(refetchedParent.slug).toStrictEqual('publish-parent-updated') + }) + + it('should update child breadcrumbs when parent is re-published with new slug', async () => { + const parentDoc = await payload.create({ + collection: 'pages', + data: { + title: 'breadcrumb-parent', + slug: 'breadcrumb-parent', + _status: 'published', + }, + }) + + const childDoc = await payload.create({ + collection: 'pages', + data: { + title: 'breadcrumb-child', + slug: 'breadcrumb-child', + parent: parentDoc.id, + _status: 'published', + }, + }) + + // Update parent to trigger resaveChildren on the child + await payload.update({ + collection: 'pages', + id: parentDoc.id, + data: { + title: 'breadcrumb-parent-v2', + slug: 'breadcrumb-parent-v2', + _status: 'published', + }, + }) + + const updatedChild = await payload.findByID({ + collection: 'pages', + id: childDoc.id, + }) + + // Breadcrumbs must be resolved values, not Promise objects or undefined + expect(updatedChild.breadcrumbs).toHaveLength(2) + expect(typeof updatedChild.breadcrumbs![0]!.label).toBe('string') + expect(typeof updatedChild.breadcrumbs![0]!.url).toBe('string') + expect(updatedChild.breadcrumbs![0]!.url).toStrictEqual('/breadcrumb-parent-v2') + expect(updatedChild.breadcrumbs![1]!.url).toStrictEqual( + '/breadcrumb-parent-v2/breadcrumb-child', + ) + }) + }) + describe('overrides', () => { let collection beforeAll(() => {