From 8b0a226d3742f36e349ba10430b3704f5ada4907 Mon Sep 17 00:00:00 2001 From: Maxime Marty-Dessus Date: Mon, 29 Sep 2025 12:01:15 +0200 Subject: [PATCH] wip --- dev/payload.config.ts | 10 +- dev/test/int/depth.spec.ts | 188 +++++++++++++++++++++++++++++++++ dev/test/int/relations.spec.ts | 3 + src/hooks/revalidate.ts | 95 ++++++++++------- src/index.ts | 20 ++-- src/lib/config-parser.ts | 4 +- src/lib/revalidation.ts | 13 ++- 7 files changed, 272 insertions(+), 61 deletions(-) create mode 100644 dev/test/int/depth.spec.ts diff --git a/dev/payload.config.ts b/dev/payload.config.ts index c7d27db..442911f 100644 --- a/dev/payload.config.ts +++ b/dev/payload.config.ts @@ -1,3 +1,5 @@ +import type { Config } from 'payload' + import { postgresAdapter } from '@payloadcms/db-postgres' import { lexicalEditor } from '@payloadcms/richtext-lexical' import path from 'path' @@ -25,7 +27,7 @@ if (!process.env.ROOT_DIR) { process.env.ROOT_DIR = dirname } -const config = buildConfig({ +export const configOptions: Config = { admin: { importMap: { baseDir: path.resolve(dirname), @@ -47,8 +49,8 @@ const config = buildConfig({ }, plugins: [ payloadRevalidate({ + defaultDepth: undefined, enable: true, - maxDepth: undefined, }), ], secret: env.PAYLOAD_SECRET, @@ -56,6 +58,8 @@ const config = buildConfig({ typescript: { outputFile: path.resolve(dirname, 'payload-types.ts'), }, -}) +} + +const config = buildConfig(configOptions) export default config diff --git a/dev/test/int/depth.spec.ts b/dev/test/int/depth.spec.ts new file mode 100644 index 0000000..b88c97b --- /dev/null +++ b/dev/test/int/depth.spec.ts @@ -0,0 +1,188 @@ +import type { Payload } from 'payload' + +import config, { configOptions } from '@payload-config' +import { getAssetsPath } from 'helpers/file.js' +import { buildConfig, getPayload } from 'payload' +import { payloadRevalidate } from 'payload-revalidate' +import { mockRevalidateTag, payload } from 'test/setup.js' +import { beforeAll, expect, test } from 'vitest' + +let mediaId: number + +beforeAll(async () => { + const media = await payload.create({ + collection: 'media', + data: { + id: 1, + alt: 'image', + }, + filePath: getAssetsPath() + '/placeholder.png', + }) + + mediaId = media.id +}) + +test('should not revalidate any relation when defaultDepth = 0', async () => { + const config = buildConfig({ + ...configOptions, + plugins: [payloadRevalidate({ defaultDepth: 0 })], + }) + + // Use custom payload instance, with modified config + const payload = await getPayload({ config }) + + let author = await payload.create({ + collection: 'authors', + data: { + name: 'added by plugin', + }, + }) + + // Verify revalidateTag was called for author creation + expect(mockRevalidateTag).toHaveBeenCalledTimes(2) + expect(mockRevalidateTag).toHaveBeenCalledWith('authors') + expect(mockRevalidateTag).toHaveBeenCalledWith(`authors.${author.id}`) + + mockRevalidateTag.mockClear() + + const post = await payload.create({ + collection: 'posts', + data: { + author: author.id, + image: mediaId, + title: 'added by plugin', + }, + depth: 2, + }) + + if (typeof post.author === 'number' || !post.author?.name) { + throw new Error('Wrong depth') + } + + expect(post.author?.name).toBe('added by plugin') + + // Verify revalidateTag was called for post creation + expect(mockRevalidateTag).toHaveBeenCalledTimes(2) + expect(mockRevalidateTag).toHaveBeenCalledWith('posts') + expect(mockRevalidateTag).toHaveBeenCalledWith(`posts.${post.id}`) + + mockRevalidateTag.mockClear() + + author = await payload.update({ + id: author.id, + collection: 'authors', + data: { + name: 'updated by plugin', + }, + }) + + // Verify that revalidateTag was called for related collections + expect(mockRevalidateTag).toHaveBeenCalledTimes(2) + expect(mockRevalidateTag).toHaveBeenCalledWith('authors') + expect(mockRevalidateTag).toHaveBeenCalledWith(`authors.${author.id}`) + + mockRevalidateTag.mockClear() + + await payload.delete({ + collection: 'authors', + where: { name: { equals: 'updated by plugin' } }, + }) + + expect(mockRevalidateTag).toHaveBeenCalledTimes(2) + expect(mockRevalidateTag).toHaveBeenCalledWith('authors') + expect(mockRevalidateTag).toHaveBeenCalledWith(`authors.${author.id}`) + + mockRevalidateTag.mockClear() + + await payload.delete({ collection: 'posts', where: { title: { equals: 'added by plugin' } } }) + + expect(mockRevalidateTag).toHaveBeenCalledTimes(2) + expect(mockRevalidateTag).toHaveBeenCalledWith('posts') + expect(mockRevalidateTag).toHaveBeenCalledWith(`posts.${post.id}`) +}) + +test('should revalidate only 1 level of relations when defaultDepth = 1', async () => { + const config = buildConfig({ + ...configOptions, + plugins: [payloadRevalidate({ defaultDepth: 1 })], + }) + + // Use custom payload instance, with modified config + const payload = await getPayload({ config }) + + // Create an author first + const author = await payload.create({ + collection: 'authors', + data: { + name: 'authorName', + }, + }) + + // Create a post with the author (depth 1) - without media to avoid upload issues + const post = await payload.create({ + collection: 'posts', + data: { + author: author.id, + image: mediaId, + title: 'postTitle', + }, + }) + + // Create a category with the post (depth 0 -> depth 1 -> depth 2) + const category = await payload.create({ + collection: 'categories', + data: { + name: 'categoryName', + description: 'categoryDescription', + featuredPost: post.id, + posts: [post.id], + }, + }) + + mockRevalidateTag.mockClear() + + // Update the author (depth 2) - this should trigger revalidation for: + // - authors collection and specific author + // - posts collection and specific post (depth 1) + await payload.update({ + id: author.id, + collection: 'authors', + data: { + name: 'authorNameUpdated', + }, + }) + + // Verify revalidateTag was called for all related collections in the chain + expect(mockRevalidateTag).toHaveBeenCalledTimes(4) + expect(mockRevalidateTag).toHaveBeenCalledWith('authors') + expect(mockRevalidateTag).toHaveBeenCalledWith(`authors.${author.id}`) + expect(mockRevalidateTag).toHaveBeenCalledWith('posts') + expect(mockRevalidateTag).toHaveBeenCalledWith(`posts.${post.id}`) + + mockRevalidateTag.mockClear() + + // Delete the author (depth 2) - this should trigger revalidation for: + // - authors collection and specific author + // - posts collection and specific post (depth 1) + await payload.delete({ + id: author.id, + collection: 'authors', + }) + + expect(mockRevalidateTag).toHaveBeenCalledTimes(4) + expect(mockRevalidateTag).toHaveBeenCalledWith('authors') + expect(mockRevalidateTag).toHaveBeenCalledWith(`authors.${author.id}`) + expect(mockRevalidateTag).toHaveBeenCalledWith('posts') + expect(mockRevalidateTag).toHaveBeenCalledWith(`posts.${post.id}`) + + mockRevalidateTag.mockClear() + + await payload.delete({ + id: post.id, + collection: 'posts', + }) + await payload.delete({ + id: category.id, + collection: 'categories', + }) +}) diff --git a/dev/test/int/relations.spec.ts b/dev/test/int/relations.spec.ts index 60ac480..ccdffa9 100644 --- a/dev/test/int/relations.spec.ts +++ b/dev/test/int/relations.spec.ts @@ -19,6 +19,9 @@ beforeAll(async () => { mediaId = media.id }) +// Tests here are using the default config, where defaultDepth = undefined +// Check depth.spec.ts for tests using a custom defaultDepth + test('revalidates correctly relations for depth 1', async () => { let author = await payload.create({ collection: 'authors', diff --git a/src/hooks/revalidate.ts b/src/hooks/revalidate.ts index afcb7c8..10f0c0a 100644 --- a/src/hooks/revalidate.ts +++ b/src/hooks/revalidate.ts @@ -10,49 +10,59 @@ import { revalidateCollectionItem, revalidateGlobalItem } from '../lib/revalidat * revalidate the page in the background, so the user doesn't have to wait * notice that the hook itself is not async and we are not awaiting `revalidate` */ -export const revalidateCollectionDelete: CollectionAfterDeleteHook = async (params) => { - if (process.env.SEED_RUN === 'true') { - return +export const getRevalidateCollectionDeleteHook = (depth?: number): CollectionAfterDeleteHook => { + return async (params) => { + if (process.env.SEED_RUN === 'true') { + return + } + /** + * TOWONDER : await here is needed, in order to ensure data is + * correctly invalidaded INSTANTANEOUSLY, and avoid querying + * old data if we use a cached query directly after modifying + * the data. However, this could reduces performances a LOT with + * complex data structures or big data sets. + * Also fixes the error : + * "Error: Route /xxxxxx used "revalidateTag xxxxxxx" during render + * which is unsupported. To ensure revalidation is performed + * consistently it must always happen outside of renders + * and cached functions." + */ + await revalidateCollectionItem({ + ...params, + depth, + }) } - /** - * TOWONDER : await here is needed, in order to ensure data is - * correctly invalidaded INSTANTANEOUSLY, and avoid querying - * old data if we use a cached query directly after modifying - * the data. However, this could reduces performances a LOT with - * complex data structures or big data sets. - * Also fixes the error : - * "Error: Route /xxxxxx used "revalidateTag xxxxxxx" during render - * which is unsupported. To ensure revalidation is performed - * consistently it must always happen outside of renders - * and cached functions." - */ - await revalidateCollectionItem(params) } -/**x +/** * revalidate the page in the background, so the user doesn't have to wait * notice that the hook itself is not async and we are not awaiting `revalidate` */ -export const revalidateCollectionChange: CollectionAfterChangeHook = async (params) => { - if (process.env.SEED_RUN === 'true') { - return - } - if (params.req.query.draft) { - return +export const getRevalidateCollectionChangeHook = (depth?: number): CollectionAfterChangeHook => { + return async (params) => { + if (process.env.SEED_RUN === 'true') { + return + } + if (params.req.query.draft) { + return + } + /** + * TOWONDER : await here is needed, in order to ensure data is + * correctly invalidaded INSTANTANEOUSLY, and avoid querying + * old data if we use a cached query directly after modifying + * the data. However, this could reduces performances a LOT with + * complex data structures or big data sets. + * Also fixes the error : + * "Error: Route /xxxxxx used "revalidateTag xxxxxxx" during render + * which is unsupported. To ensure revalidation is performed + * consistently it must always happen outside of renders + * and cached functions." + */ + await revalidateCollectionItem({ + ...params, + depth, + }) } - /** - * TOWONDER : await here is needed, in order to ensure data is - * correctly invalidaded INSTANTANEOUSLY, and avoid querying - * old data if we use a cached query directly after modifying - * the data. However, this could reduces performances a LOT with - * complex data structures or big data sets. - * Also fixes the error : - * "Error: Route /xxxxxx used "revalidateTag xxxxxxx" during render - * which is unsupported. To ensure revalidation is performed - * consistently it must always happen outside of renders - * and cached functions." - */ - await revalidateCollectionItem(params) } /** @@ -62,9 +72,14 @@ export const revalidateCollectionChange: CollectionAfterChangeHook = async (para * send `revalidatePath`, `collection`, and `slug` to the frontend to use in its revalidate route * frameworks may have different ways of doing this, but the idea is the same */ -export const revalidateGlobal: GlobalAfterChangeHook = (params) => { - if (process.env.SEED_RUN === 'true') { - return +export const getRevalidateGlobalHook = (depth?: number): GlobalAfterChangeHook => { + return (params) => { + if (process.env.SEED_RUN === 'true') { + return + } + void revalidateGlobalItem({ + ...params, + depth, + }) } - void revalidateGlobalItem(params) } diff --git a/src/index.ts b/src/index.ts index aac4148..c0c8e5a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,24 +1,20 @@ import type { Config } from 'payload' import { - revalidateCollectionChange, - revalidateCollectionDelete, - revalidateGlobal, + getRevalidateCollectionChangeHook, + getRevalidateCollectionDeleteHook, + getRevalidateGlobalHook, } from './hooks/revalidate.js' export type PayloadRevalidateConfig = { + defaultDepth?: number enable?: boolean - /** - * Maximum depth of relations to follow - * If undefined, will follow all relations - */ - maxDepth?: number } export const payloadRevalidate = (pluginOptions: PayloadRevalidateConfig) => (config: Config): Config => { - const { enable = true } = pluginOptions + const { defaultDepth, enable = true } = pluginOptions if (!enable) { return config @@ -36,8 +32,8 @@ export const payloadRevalidate = collection.hooks.afterDelete = [] } // Revalidation hooks should be trigger at the end of the hooks chain - collection.hooks.afterChange.push(revalidateCollectionChange) - collection.hooks.afterDelete.push(revalidateCollectionDelete) + collection.hooks.afterChange.push(getRevalidateCollectionChangeHook(defaultDepth)) + collection.hooks.afterDelete.push(getRevalidateCollectionDeleteHook(defaultDepth)) } } @@ -50,7 +46,7 @@ export const payloadRevalidate = global.hooks.afterChange = [] } - global.hooks.afterChange.push(revalidateGlobal) + global.hooks.afterChange.push(getRevalidateGlobalHook(defaultDepth)) } } diff --git a/src/lib/config-parser.ts b/src/lib/config-parser.ts index f905363..7a7d39a 100644 --- a/src/lib/config-parser.ts +++ b/src/lib/config-parser.ts @@ -140,12 +140,12 @@ export const extractRelationFieldPaths = ( * traverses into related collections to build a comprehensive tree of relationships. * * @param config - The full Payload configuration - * @param maxDepth - Maximum depth to traverse (default: 3, set to 0 for unlimited) + * @param maxDepth - Maximum depth to traverse (set to 0 for unlimited) * @returns Object with collections and globals relation paths */ export const buildRelationTree = ( config: SanitizedConfig, - maxDepth: number = 3, + maxDepth: number = 0, ): RelationTreeResult => { const result: RelationTreeResult = { collections: {}, diff --git a/src/lib/revalidation.ts b/src/lib/revalidation.ts index 4707d61..1ca955a 100644 --- a/src/lib/revalidation.ts +++ b/src/lib/revalidation.ts @@ -15,6 +15,7 @@ export interface RevalidateCollectionParams { /** The collection which this hook is being run on */ collection: SanitizedCollectionConfig context: RequestContext + depth?: number doc: T req: PayloadRequest } @@ -30,7 +31,7 @@ export const revalidateCollectionItem = async ( // Use a set to avoid duplicate tags and improve readability const tagsToRevalidate = new Set() - const { collection, doc } = params + const { collection, depth, doc } = params const collectionSlug = collection?.slug tagsToRevalidate.add(collectionSlug) @@ -52,6 +53,7 @@ export const revalidateCollectionItem = async ( // Recursively handle relations nested inside containers like "blocks", "array", and "group". const relationTags = await getTagsFromRelations({ context: 'collection', + depth, docId: doc.id, modifiedSlug: collectionSlug, payload, @@ -71,6 +73,7 @@ export const revalidateCollectionItem = async ( export interface RevalidateGlobalParams { context: RequestContext + depth?: number doc: T global: SanitizedGlobalConfig req: PayloadRequest @@ -82,7 +85,7 @@ export const revalidateGlobalItem = async (params: RevalidateGlobalParams): Prom // Use a set to avoid duplicate tags and improve readability const tagsToRevalidate = new Set() - const { doc, global } = params + const { depth: defaultDepth, doc, global } = params const globalSlug = global?.slug tagsToRevalidate.add(globalSlug) @@ -91,6 +94,7 @@ export const revalidateGlobalItem = async (params: RevalidateGlobalParams): Prom // Recursively handle relations nested inside containers like "blocks", "array", and "group". const relationTags = await getTagsFromRelations({ context: 'global', + depth: defaultDepth, docId: doc.id, modifiedSlug: globalSlug, payload, @@ -113,16 +117,17 @@ export const revalidateGlobalItem = async (params: RevalidateGlobalParams): Prom */ const getTagsFromRelations = async (params: { context: 'collection' | 'global' + depth?: number docId: number | string modifiedSlug: string payload: PayloadRequest['payload'] }): Promise> => { - const { context, docId, modifiedSlug, payload } = params + const { context, depth, docId, modifiedSlug, payload } = params const config = payload.config const tagsToRevalidate = new Set() // Get relation trees for all collections and globals at once - const allRelationTrees = buildRelationTree(config) + const allRelationTrees = buildRelationTree(config, depth) // Handle collections for (const configCollection of config.collections) {