From 864c1037dd95d6a4256b61ecd2172725869cbc69 Mon Sep 17 00:00:00 2001 From: Luan Taraschi <130802253+luantaraschi@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:49:14 -0300 Subject: [PATCH] fix(plugin-cloud-storage): don't leave skipCloudStorage on a reused context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The afterChange hook sets `skipCloudStorage` on `req.context`, then calls a nested `payload.update()`. That runs `createLocalReq`, which reassigns `req.context` to a fresh copy — so the `finally` cleared the flag off the copy and left it on the original object. When a caller passes one `context` object across several Local API creates, `createLocalReq` binds that same object to `req.context`, so the leftover flag makes every later upload return early. The documents are written with correct filenames and sizes, but no bytes reach storage and nothing is logged. --- .../src/hooks/afterChange.ts | 14 ++++++-- test/plugin-cloud-storage/int.spec.ts | 32 +++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/plugin-cloud-storage/src/hooks/afterChange.ts b/packages/plugin-cloud-storage/src/hooks/afterChange.ts index 866756e2d5a..2e4e2c3dde8 100644 --- a/packages/plugin-cloud-storage/src/hooks/afterChange.ts +++ b/packages/plugin-cloud-storage/src/hooks/afterChange.ts @@ -54,7 +54,17 @@ export const getAfterChangeHook = if (!req.context) { req.context = {} } - req.context.skipCloudStorage = true + + // Hold onto the object the flag is set on. The nested update below runs + // `createLocalReq`, which reassigns `req.context` to a fresh copy — so + // clearing `req.context.skipCloudStorage` afterwards would clear it off + // that copy and leave it set on the original. When a caller reuses one + // `context` across several Local API creates (the seed-script pattern), + // `createLocalReq` hands that same object to `req.context`, and the + // leftover flag makes every later upload return early: the document is + // written but no bytes reach storage. + const contextWithFlag = req.context + contextWithFlag.skipCloudStorage = true // Clear to prevent re-processing req.file = undefined @@ -70,7 +80,7 @@ export const getAfterChangeHook = req, }) } finally { - delete req.context.skipCloudStorage + delete contextWithFlag.skipCloudStorage } docWithMetadata = { ...doc, ...uploadMetadata } diff --git a/test/plugin-cloud-storage/int.spec.ts b/test/plugin-cloud-storage/int.spec.ts index 1d56040d5aa..10c937b77c9 100644 --- a/test/plugin-cloud-storage/int.spec.ts +++ b/test/plugin-cloud-storage/int.spec.ts @@ -323,6 +323,38 @@ describe('@payloadcms/plugin-cloud-storage', () => { expect(upload.url).toEqual(`/api/${mediaSlug}/file/${String(upload.filename)}`) }) + it('uploads every file when one context object is reused across creates', async () => { + // `createLocalReq` assigns this exact object to `req.context`, so + // anything a hook writes onto it survives into the next create. + const sharedContext: Record = {} + + const uploads = [] + + for (let i = 0; i < 3; i++) { + uploads.push( + await payload.create({ + collection: mediaSlug, + context: sharedContext, + data: {}, + filePath: path.resolve(dirname, '../uploads/image.png'), + }), + ) + } + + // The documents are written either way — only the bytes go missing. + for (const upload of uploads) { + await verifyUploads({ + client, + collectionSlug: mediaSlug, + payload, + TEST_BUCKET, + uploadId: upload.id, + }) + } + + expect(sharedContext).not.toHaveProperty('skipCloudStorage') + }) + it('can upload with prefix', async () => { const upload = await payload.create({ collection: mediaWithPrefixSlug,