Skip to content

Commit 783b0fb

Browse files
committed
feat: expose pageTreeMoveContextKey to skip deploy hooks on tree moves
1 parent 1797eac commit 783b0fb

12 files changed

Lines changed: 548 additions & 8 deletions

File tree

README.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,64 @@ nestedDocsPageTreePlugin({
117117

118118
`labels` and `colors` are optional partial overrides. Missing entries fall back to the built-in defaults.
119119

120+
## Drag-And-Drop Is Triggering A Deploy?
121+
122+
A drag-and-drop move calls `payload.update()` on the draft only. The published version of the live site is never touched. So in most setups, dragging a page does not trigger any rebuild and you can skip this section.
123+
124+
### When you can skip this section
125+
126+
- The default Payload website template on Vercel (or any host using Next.js ISR), with drafts and autosave on. The template's `afterChange` hook only calls `revalidatePath` and `revalidateTag` from `next/cache`. Those just clear the edge cache. They do not trigger a Vercel build, do not consume build minutes, and do not change what visitors see when the published HTML hasn't changed.
127+
- Any setup where your `afterChange` hooks only do in-process cache work (`revalidatePath`, `revalidateTag`, in-memory caches, etc.).
128+
129+
### When you need the one-line fix
130+
131+
You need the fix if **you** wrote an `afterChange` hook that calls something external or expensive on every save. Common cases:
132+
133+
- **Cloudflare Pages / Netlify / Vercel Deploy Hooks** (`fetch(DEPLOY_HOOK_URL)`) — these trigger full rebuilds and burn build minutes.
134+
- **GitHub Actions** `repository_dispatch` triggers.
135+
- **Manually-invoked SSG rebuilds**.
136+
- **Publish notifications** (email, Slack) on status transitions.
137+
- **Heavy search reindex jobs** (Algolia, Meilisearch full-document push).
138+
139+
Why a tree move trips these: a typical deploy hook fires when `previousDoc?._status === 'published'` so that it catches unpublish events too. A tree move on a published doc matches that condition (the previous draft state was published) — but the live site hasn't actually changed. Without the fix, every drag fires your deploy.
140+
141+
### The fix
142+
143+
Add one line at the top of your hook. The plugin sets a flag on Payload's [hook context](https://payloadcms.com/docs/hooks/context) for every move, and your hook reads it to bail out early:
144+
145+
```ts
146+
import { pageTreeMoveContextKey } from 'payload-nested-docs-page-tree'
147+
148+
// at the top of your afterChange hook:
149+
if (req.context?.[pageTreeMoveContextKey]) return
150+
```
151+
152+
This goes in **your** hook — the one that calls the deploy webhook. Not in any of the template's stock files.
153+
154+
Full example:
155+
156+
```ts
157+
import type { CollectionAfterChangeHook } from 'payload'
158+
import { pageTreeMoveContextKey } from 'payload-nested-docs-page-tree'
159+
160+
export const triggerDeployOnPublishedChange: CollectionAfterChangeHook = async ({
161+
doc,
162+
previousDoc,
163+
req,
164+
}) => {
165+
// -- plugin opt-out --
166+
if (req.context?.[pageTreeMoveContextKey]) return
167+
168+
// -- your deploy logic (example) --
169+
// Fire on publish, republish, or unpublish — every transition the live site cares about.
170+
if (doc._status === 'published' || previousDoc?._status === 'published') {
171+
// POST to your Cloudflare / Netlify / Vercel deploy hook here
172+
}
173+
}
174+
```
175+
176+
See `dev/lib/rebuild.ts` for the full reference example.
177+
120178
## Configuration
121179

122180
- `collections`: target collection slugs

dev/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
DATABASE_URL=mongodb://127.0.0.1/payload-plugin-template
22
PAYLOAD_SECRET=YOUR_SECRET_HERE
3+
CLOUDFLARE_DEPLOY_HOOK_URL=

dev/app/(payload)/admin/importMap.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { SlugField as SlugField_2b8867833a34864a02ddf429b0728a40 } from '@payloa
22
import { NestedDocsPageTreeListView as NestedDocsPageTreeListView_5b6451e48750080604c1786eb7b97ca9 } from 'payload-nested-docs-page-tree/rsc'
33
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
44

5+
/** @type import('payload').ImportMap */
56
export const importMap = {
67
"@payloadcms/next/client#SlugField": SlugField_2b8867833a34864a02ddf429b0728a40,
78
"payload-nested-docs-page-tree/rsc#NestedDocsPageTreeListView": NestedDocsPageTreeListView_5b6451e48750080604c1786eb7b97ca9,

dev/lib/rebuild.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import type {
2+
CollectionAfterChangeHook,
3+
CollectionAfterDeleteHook,
4+
PayloadRequest,
5+
} from 'payload'
6+
7+
import { pageTreeMoveContextKey } from 'payload-nested-docs-page-tree'
8+
9+
const postDeployHook = async (source: string, req: PayloadRequest): Promise<void> => {
10+
const url = process.env.CLOUDFLARE_DEPLOY_HOOK_URL
11+
12+
if (!url) {
13+
return
14+
}
15+
16+
try {
17+
await fetch(url, {
18+
body: JSON.stringify({ source }),
19+
headers: { 'content-type': 'application/json' },
20+
method: 'POST',
21+
})
22+
} catch (error) {
23+
req.payload.logger.error(error)
24+
}
25+
}
26+
27+
/**
28+
* Trigger a deploy when a doc crosses the published boundary:
29+
* draft → published (publish / republish)
30+
* published → draft (unpublish)
31+
*
32+
* Skips tree-only parent moves and autosave noise.
33+
*/
34+
export const revalidatePublishedChange =
35+
(source: string): CollectionAfterChangeHook =>
36+
async ({ doc, previousDoc, req }) => {
37+
if (req.context?.[pageTreeMoveContextKey]) return
38+
if (req.url?.includes('autosave=true')) return
39+
40+
if (doc._status === 'published' || previousDoc?._status === 'published') {
41+
await postDeployHook(source, req)
42+
}
43+
}
44+
45+
/** Trigger a deploy when a published doc is deleted. */
46+
export const revalidateOnDelete =
47+
(source: string): CollectionAfterDeleteHook =>
48+
async ({ doc, req }) => {
49+
if (doc?._status !== 'published') return
50+
51+
await postDeployHook(source, req)
52+
}

dev/payload.config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import sharp from 'sharp'
99
import { fileURLToPath } from 'url'
1010

1111
import { testEmailAdapter } from './helpers/testEmailAdapter.js'
12+
import { revalidateOnDelete, revalidatePublishedChange } from './lib/rebuild.js'
1213
import { seed } from './seed.js'
1314

1415
const filename = fileURLToPath(import.meta.url)
@@ -67,6 +68,10 @@ const buildPageLikeCollection = (args: {
6768
},
6869
slugField(),
6970
],
71+
hooks: {
72+
afterChange: [revalidatePublishedChange(args.slug)],
73+
afterDelete: [revalidateOnDelete(args.slug)],
74+
},
7075
versions: {
7176
drafts: {
7277
autosave: {

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "payload-nested-docs-page-tree",
3-
"version": "1.2.2",
3+
"version": "1.2.3",
44
"description": "Page tree plugin for Payload nested docs collections",
55
"license": "MIT",
66
"author": "WLF Studios",

src/components/PageTreeListView.client.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,11 @@ import {
2727
import { useRouter, useSearchParams } from 'next/navigation'
2828
import React from 'react'
2929

30-
import type {
31-
NestedDocsPageTreePluginResolvedBadgeConfig,
32-
PageTreeSourceDoc,
30+
import {
31+
pageTreeMoveRequestHeader,
32+
pageTreeMoveRequestHeaderValue,
33+
type NestedDocsPageTreePluginResolvedBadgeConfig,
34+
type PageTreeSourceDoc,
3335
} from '../types.js'
3436

3537
import {
@@ -942,6 +944,7 @@ export default function PageTreeListViewClient({
942944
headers: {
943945
'Accept-Language': i18n.language,
944946
'Content-Type': 'application/json',
947+
[pageTreeMoveRequestHeader]: pageTreeMoveRequestHeaderValue,
945948
},
946949
method: 'POST',
947950
},
@@ -996,6 +999,7 @@ export default function PageTreeListViewClient({
996999
headers: {
9971000
'Accept-Language': i18n.language,
9981001
'Content-Type': 'application/json',
1002+
[pageTreeMoveRequestHeader]: pageTreeMoveRequestHeaderValue,
9991003
},
10001004
method: 'POST',
10011005
},

src/endpoints/createMovePageEndpoint.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
getDocParentID,
88
stringifyDocID,
99
} from '../utilities/pageTree.js'
10-
import type { PageTreeSourceDoc } from '../types.js'
10+
import { pageTreeMoveContextKey, type PageTreeSourceDoc } from '../types.js'
1111

1212
type MoveDocumentRequestBody = {
1313
parentID: null | string
@@ -326,6 +326,9 @@ export function createMovePageEndpoint(args: {
326326

327327
await req.payload.update({
328328
collection: collectionSlug as never,
329+
context: {
330+
[pageTreeMoveContextKey]: true,
331+
},
329332
data: {
330333
[parentFieldSlug]:
331334
body.parentID === null

src/index.spec.ts

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1-
import type { CollectionConfig, Config } from 'payload'
1+
import type { CollectionBeforeChangeHook, CollectionConfig, Config } from 'payload'
22

33
import { describe, expect, it } from 'vitest'
44

55
import { nestedDocsPageTreePlugin } from './index.js'
6+
import {
7+
pageTreeMoveContextKey,
8+
pageTreeMoveRequestHeader,
9+
pageTreeMoveRequestHeaderValue,
10+
} from './types.js'
611

712
type CollectionEndpoint = NonNullable<Exclude<CollectionConfig['endpoints'], false>>[number]
813

@@ -14,6 +19,7 @@ function buildCollection(args: {
1419
endpointPath?: string
1520
includeBreadcrumbs?: boolean
1621
includeParent?: boolean
22+
orderable?: boolean
1723
paginationDefaultLimit?: number
1824
parentFieldSlug?: string
1925
slug: string
@@ -25,6 +31,7 @@ function buildCollection(args: {
2531
endpointPath,
2632
includeBreadcrumbs = true,
2733
includeParent = true,
34+
orderable = false,
2835
paginationDefaultLimit,
2936
parentFieldSlug = 'parent',
3037
slug,
@@ -87,6 +94,7 @@ function buildCollection(args: {
8794
]
8895
: undefined,
8996
fields,
97+
...(orderable ? { orderable: true } : {}),
9098
slug,
9199
}
92100
}
@@ -235,6 +243,95 @@ describe('nestedDocsPageTreePlugin', () => {
235243
expect(config.collections?.[0]?.admin?.pagination?.defaultLimit).toBe(25)
236244
})
237245

246+
it('tags page-tree reorder requests with the move context before user hooks run', async () => {
247+
let userHookSawMoveContext = false
248+
const userBeforeChangeHook: CollectionBeforeChangeHook = ({ data, req }) => {
249+
userHookSawMoveContext = req.context?.[pageTreeMoveContextKey] === true
250+
251+
return data
252+
}
253+
const config = nestedDocsPageTreePlugin({
254+
collections: ['pages'],
255+
})(
256+
buildConfig([
257+
{
258+
...buildCollection({
259+
orderable: true,
260+
slug: 'pages',
261+
}),
262+
hooks: {
263+
beforeChange: [userBeforeChangeHook],
264+
},
265+
},
266+
]),
267+
)
268+
const hooks = config.collections?.[0]?.hooks?.beforeChange ?? []
269+
const req = {
270+
context: {},
271+
headers: new Headers({
272+
[pageTreeMoveRequestHeader]: pageTreeMoveRequestHeaderValue,
273+
}),
274+
user: { id: 'user-1' },
275+
}
276+
let data: Record<string, unknown> = {
277+
title: 'About',
278+
}
279+
280+
for (const hook of hooks) {
281+
data = ((await hook({ data, req } as never)) ?? data) as Record<string, unknown>
282+
}
283+
284+
expect(hooks).toHaveLength(2)
285+
expect(req.context).toMatchObject({
286+
[pageTreeMoveContextKey]: true,
287+
})
288+
expect(userHookSawMoveContext).toBe(true)
289+
})
290+
291+
it('does not tag ordinary orderable updates as page-tree moves', async () => {
292+
const config = nestedDocsPageTreePlugin({
293+
collections: ['pages'],
294+
})(buildConfig([buildCollection({ orderable: true, slug: 'pages' })]))
295+
const hook = config.collections?.[0]?.hooks?.beforeChange?.[0]
296+
const req = {
297+
context: {},
298+
headers: new Headers(),
299+
user: { id: 'user-1' },
300+
}
301+
302+
await hook?.({
303+
data: {
304+
title: 'About',
305+
},
306+
req,
307+
} as never)
308+
309+
expect(req.context).not.toHaveProperty(pageTreeMoveContextKey)
310+
})
311+
312+
it('does not tag the move context when the request has no authenticated user', async () => {
313+
const config = nestedDocsPageTreePlugin({
314+
collections: ['pages'],
315+
})(buildConfig([buildCollection({ orderable: true, slug: 'pages' })]))
316+
const hook = config.collections?.[0]?.hooks?.beforeChange?.[0]
317+
const req = {
318+
context: {},
319+
headers: new Headers({
320+
[pageTreeMoveRequestHeader]: pageTreeMoveRequestHeaderValue,
321+
}),
322+
user: null,
323+
}
324+
325+
await hook?.({
326+
data: {
327+
title: 'About',
328+
},
329+
req,
330+
} as never)
331+
332+
expect(req.context).not.toHaveProperty(pageTreeMoveContextKey)
333+
})
334+
238335
it('returns the original config when the plugin is disabled', () => {
239336
const config = buildConfig([
240337
buildCollection({

0 commit comments

Comments
 (0)