Skip to content

fix(plugin-cloud-storage): prevent draft file reupload from unpublishing the document - #17030

Merged
GermanJablo merged 3 commits into
mainfrom
fix/cloud-storage-draft-upload-unpublish
Jun 17, 2026
Merged

fix(plugin-cloud-storage): prevent draft file reupload from unpublishing the document#17030
GermanJablo merged 3 commits into
mainfrom
fix/cloud-storage-draft-upload-unpublish

Conversation

@GermanJablo

Copy link
Copy Markdown
Contributor

What

Fixes #17016.

On an upload collection with drafts enabled, replacing the file and clicking Save draft on a published document used to:

  • flip the main document's _status from published to draft (effectively unpublishing it), and
  • delete the published file from storage.

This only reproduced with cloud storage adapters (S3, Azure, GCS, R2, Vercel Blob, Uploadthing) and only when the file itself was reuploaded, which is why the earlier fix (#16853, tested with local storage) did not resolve it.

Root cause

The cloud storage afterChange hook persists adapter-returned upload metadata through a nested payload.update. Adapters such as S3 return the full data object from handleUpload, so uploadMetadata was the entire draft document (including _status: 'draft'). Because the nested update did not preserve the draft state, the draft document was written onto the published main row, unpublishing it.

The same hook then deleted the previous file, which is still referenced by the published document.

Fix

In packages/plugin-cloud-storage/src/hooks/afterChange.ts:

  • Pass the originating draft state (draft: isDraftSave) to the nested payload.update so metadata is persisted to the draft version instead of the published main document.
  • Skip deleting the previous file when saving a draft over a published document (isDraftOverPublished), mirroring the existing core isDraftOverPublished guard, since the published file is still in use.

Non-draft updates and collections without drafts are unaffected (draft: false is equivalent to the prior behavior).

Tests

Added a draft-with-upload-cloud-storage collection to the versions suite backed by a mock cloud storage adapter that mirrors the real S3 adapter (its handleUpload returns the full data). New integration tests cover:

  • Saving a draft with a new file does not unpublish the main document nor delete its file.
  • Publishing the draft afterwards correctly promotes the draft file.
  • A normal (non-draft) update still persists adapter metadata to the main document.

Test plan

  • pnpm run test:int versions (105 passed)
  • New cloud storage draft tests pass
  • Lint clean on changed files

…ing the document

When an upload collection has drafts enabled, replacing the file and clicking
"Save draft" on a published document used to flip the main document's _status
from published to draft (effectively unpublishing it) and delete the published
file from storage. This only happened with cloud storage adapters and only when
the file itself was reuploaded.

The cloud storage afterChange hook persists adapter-returned upload metadata via
a nested payload.update. Adapters like S3 return the full data object, so the
nested update wrote the draft document (including _status: draft) onto the
published main row because it did not preserve the draft state. The same hook
also deleted the previous file, which is still referenced by the published
document.

Pass the originating draft state to the nested update so metadata is persisted to
the draft version instead of the published main document, and skip deleting the
previous file when saving a draft over a published document.
@github-actions

Copy link
Copy Markdown
Contributor

📦 esbuild Bundle Analysis for payload

This analysis was generated by esbuild-bundle-analyzer. 🤖

Meta File Out File Size (raw) Note
packages/next/meta_index.json esbuild/index.js 201.84 KB ✅ No change
packages/payload/meta_index.json esbuild/index.js 1.39 MB ⚠️ +185 B (+0.0%)
packages/payload/meta_shared.json esbuild/exports/shared.js 192.64 KB ⚠️ +102 B (+0.1%)
packages/richtext-lexical/meta_client.json esbuild/exports/client_optimized/index.js 304.96 KB ✅ No change
packages/ui/meta_client.json esbuild/exports/client_optimized/index.js 1.37 MB ⚠️ +552 B (+0.0%)
packages/ui/meta_shared.json esbuild/exports/shared_optimized/index.js 18.66 KB ✅ No change
Largest paths These visualization shows top 20 largest paths in the bundle.

Meta file: packages/next/meta_index.json, Out file: esbuild/index.js

Path Size
../../node_modules ${{\color{Goldenrod}{ ████████████████████████▋ }}}$ 98.9%, 197.86 KB
dist/adapters/router.js ${{\color{Goldenrod}{ }}}$ 0.3%, 663 B
dist/adapters/server.js ${{\color{Goldenrod}{ }}}$ 0.3%, 533 B
dist/adapters/layout.js ${{\color{Goldenrod}{ }}}$ 0.3%, 526 B
dist/adapters/views.js ${{\color{Goldenrod}{ }}}$ 0.2%, 409 B
dist/esbuildEntry.js ${{\color{Goldenrod}{ }}}$ 0.0%, 0 B

Meta file: packages/payload/meta_index.json, Out file: esbuild/index.js

Path Size
../../node_modules ${{\color{Goldenrod}{ █████████████████▎ }}}$ 69.3%, 960.51 KB
dist/fields/hooks ${{\color{Goldenrod}{ ▊ }}}$ 3.2%, 43.88 KB
dist/collections/operations ${{\color{Goldenrod}{ ▋ }}}$ 2.9%, 40.22 KB
dist/auth/operations ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 15.63 KB
dist/utilities/configToJSONSchema.js ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 14.93 KB
dist/globals/operations ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 13.36 KB
dist/fields/config ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 13.22 KB
dist/queues/operations ${{\color{Goldenrod}{ ▏ }}}$ 0.9%, 12.63 KB
dist/fields/validations.js ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 10.57 KB
dist/bin/generateImportMap ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 9.67 KB
dist/collections/config ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 9.21 KB
dist/config/orderable ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 8.07 KB
dist/uploads/fetchAPI-multipart ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 7.80 KB
dist/index.js ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 7.77 KB
dist/hierarchy/utils ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 7.64 KB
dist/database/migrations ${{\color{Goldenrod}{ ▏ }}}$ 0.5%, 7.55 KB
dist/collections/endpoints ${{\color{Goldenrod}{ }}}$ 0.4%, 6.12 KB
dist/queues/config ${{\color{Goldenrod}{ }}}$ 0.4%, 5.59 KB
dist/auth/strategies ${{\color{Goldenrod}{ }}}$ 0.4%, 5.43 KB
dist/utilities/telemetry ${{\color{Goldenrod}{ }}}$ 0.4%, 5.31 KB
(other) ${{\color{Goldenrod}{ ███████▋ }}}$ 30.7%, 425.85 KB

Meta file: packages/payload/meta_shared.json, Out file: esbuild/exports/shared.js

Path Size
../../node_modules ${{\color{Goldenrod}{ ███████████████████▉ }}}$ 79.5%, 150.12 KB
dist/fields/validations.js ${{\color{Goldenrod}{ █▍ }}}$ 5.6%, 10.57 KB
dist/config/orderable ${{\color{Goldenrod}{ ▍ }}}$ 1.7%, 3.13 KB
dist/fields/baseFields ${{\color{Goldenrod}{ ▍ }}}$ 1.5%, 2.79 KB
dist/utilities/deepCopyObject.js ${{\color{Goldenrod}{ ▎ }}}$ 1.4%, 2.69 KB
dist/auth/cookies.js ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 1.55 KB
dist/utilities/flattenTopLevelFields.js ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 1.42 KB
dist/fields/config ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 1.29 KB
dist/utilities/getVersionsConfig.js ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 1.04 KB
dist/utilities/flattenAllFields.js ${{\color{Goldenrod}{ }}}$ 0.4%, 794 B
dist/utilities/unflatten.js ${{\color{Goldenrod}{ }}}$ 0.4%, 779 B
dist/utilities/sanitizeUserDataForEmail.js ${{\color{Goldenrod}{ }}}$ 0.4%, 713 B
dist/utilities/getFieldPermissions.js ${{\color{Goldenrod}{ }}}$ 0.3%, 651 B
dist/collections/config ${{\color{Goldenrod}{ }}}$ 0.3%, 570 B
dist/bin/generateImportMap ${{\color{Goldenrod}{ }}}$ 0.3%, 561 B
dist/auth/sessions.js ${{\color{Goldenrod}{ }}}$ 0.3%, 525 B
dist/fields/getFieldPaths.js ${{\color{Goldenrod}{ }}}$ 0.3%, 485 B
dist/utilities/appendDateTimezoneSelectFields.js ${{\color{Goldenrod}{ }}}$ 0.2%, 432 B
dist/utilities/getSafeRedirect.js ${{\color{Goldenrod}{ }}}$ 0.2%, 423 B
dist/utilities/deepMerge.js ${{\color{Goldenrod}{ }}}$ 0.2%, 413 B
(other) ${{\color{Goldenrod}{ █████▏ }}}$ 20.5%, 38.71 KB

Meta file: packages/richtext-lexical/meta_client.json, Out file: esbuild/exports/client_optimized/index.js

Path Size
dist/features/blocks ${{\color{Goldenrod}{ ███ }}}$ 12.3%, 37.13 KB
dist/lexical/ui ${{\color{Goldenrod}{ ██▊ }}}$ 11.3%, 34.16 KB
dist/lexical/plugins ${{\color{Goldenrod}{ ██▋ }}}$ 10.9%, 32.88 KB
dist/features/experimental_table ${{\color{Goldenrod}{ ██▎ }}}$ 9.0%, 27.16 KB
dist/packages/@lexical ${{\color{Goldenrod}{ █▌ }}}$ 6.3%, 18.99 KB
dist/features/link ${{\color{Goldenrod}{ █▌ }}}$ 6.2%, 18.83 KB
dist/features/toolbars ${{\color{Goldenrod}{ █▍ }}}$ 5.5%, 16.58 KB
dist/features/upload ${{\color{Goldenrod}{ █▏ }}}$ 4.7%, 14.09 KB
dist/features/textState ${{\color{Goldenrod}{ ▉ }}}$ 3.7%, 11.08 KB
dist/features/relationship ${{\color{Goldenrod}{ ▊ }}}$ 3.2%, 9.61 KB
dist/lexical/utils ${{\color{Goldenrod}{ ▋ }}}$ 2.9%, 8.79 KB
dist/features/converters ${{\color{Goldenrod}{ ▋ }}}$ 2.8%, 8.36 KB
dist/utilities/fieldsDrawer ${{\color{Goldenrod}{ ▋ }}}$ 2.7%, 8.12 KB
dist/features/debug ${{\color{Goldenrod}{ ▋ }}}$ 2.5%, 7.40 KB
dist/lexical/config ${{\color{Goldenrod}{ ▍ }}}$ 1.7%, 5.08 KB
dist/features/lists ${{\color{Goldenrod}{ ▍ }}}$ 1.7%, 5.00 KB
dist/features/format ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 3.46 KB
dist/lexical/LexicalEditor.js ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 3.23 KB
dist/features/horizontalRule ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 3.18 KB
dist/field/Field.js ${{\color{Goldenrod}{ ▏ }}}$ 0.9%, 2.83 KB
(other) ${{\color{Goldenrod}{ █████████████████████▉ }}}$ 87.7%, 264.60 KB

Meta file: packages/ui/meta_client.json, Out file: esbuild/exports/client_optimized/index.js

Path Size
../../node_modules ${{\color{Goldenrod}{ ██████████▋ }}}$ 42.6%, 580.42 KB
dist/elements/Hierarchy ${{\color{Goldenrod}{ ▊ }}}$ 3.2%, 43.02 KB
dist/elements/BulkUpload ${{\color{Goldenrod}{ ▌ }}}$ 2.1%, 28.33 KB
dist/views/Version ${{\color{Goldenrod}{ ▌ }}}$ 2.0%, 27.40 KB
dist/views/HierarchyList ${{\color{Goldenrod}{ ▍ }}}$ 1.5%, 20.43 KB
dist/elements/Table ${{\color{Goldenrod}{ ▎ }}}$ 1.4%, 19.38 KB
dist/views/Dashboard ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 17.73 KB
dist/views/Edit ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 17.45 KB
dist/elements/WhereBuilder ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 17.39 KB
dist/forms/Form ${{\color{Goldenrod}{ ▎ }}}$ 1.2%, 15.89 KB
dist/fields/Relationship ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 15.48 KB
dist/fields/Blocks ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 15.06 KB
dist/fields/Upload ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 14.40 KB
dist/elements/QueryPresets ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 10.22 KB
dist/elements/ReactSelect ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 9.07 KB
dist/views/List ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 8.78 KB
dist/elements/PublishButton ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 8.77 KB
dist/elements/HTMLDiff ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 8.38 KB
dist/elements/LivePreview ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 8.26 KB
dist/elements/UserMenu ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 8.17 KB
(other) ${{\color{Goldenrod}{ ██████████████▎ }}}$ 57.4%, 781.00 KB

Meta file: packages/ui/meta_shared.json, Out file: esbuild/exports/shared_optimized/index.js

Path Size
dist/graphics/Logo ${{\color{Goldenrod}{ ███████▋ }}}$ 30.9%, 5.57 KB
../../node_modules ${{\color{Goldenrod}{ ███▋ }}}$ 14.7%, 2.65 KB
dist/graphics/Icon ${{\color{Goldenrod}{ ██▏ }}}$ 8.5%, 1.52 KB
dist/utilities/formatDocTitle ${{\color{Goldenrod}{ █▊ }}}$ 7.3%, 1.32 KB
dist/providers/TableColumns ${{\color{Goldenrod}{ █▏ }}}$ 4.8%, 866 B
dist/utilities/getGlobalData.js ${{\color{Goldenrod}{ █ }}}$ 4.2%, 762 B
dist/utilities/api.js ${{\color{Goldenrod}{ █ }}}$ 4.2%, 756 B
dist/utilities/groupNavItems.js ${{\color{Goldenrod}{ █ }}}$ 4.1%, 745 B
dist/elements/Translation ${{\color{Goldenrod}{ ▋ }}}$ 2.7%, 493 B
dist/utilities/handleTakeOver.js ${{\color{Goldenrod}{ ▌ }}}$ 2.4%, 440 B
dist/utilities/traverseForLocalizedFields.js ${{\color{Goldenrod}{ ▌ }}}$ 2.3%, 419 B
dist/elements/withMergedProps ${{\color{Goldenrod}{ ▍ }}}$ 1.9%, 339 B
dist/utilities/getNavGroups.js ${{\color{Goldenrod}{ ▍ }}}$ 1.9%, 338 B
dist/utilities/getVisibleEntities.js ${{\color{Goldenrod}{ ▍ }}}$ 1.8%, 329 B
dist/elements/WithServerSideProps ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 232 B
dist/utilities/handleGoBack.js ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 180 B
dist/fields/mergeFieldStyles.js ${{\color{Goldenrod}{ ▏ }}}$ 0.9%, 157 B
dist/utilities/handleBackToDashboard.js ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 152 B
dist/forms/Form ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 148 B
dist/utilities/abortAndIgnore.js ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 146 B
(other) ${{\color{Goldenrod}{ █████████████████▎ }}}$ 69.1%, 12.47 KB
Details

Next to the size is how much the size has increased or decreased compared with the base branch of this PR.

  • ‼️: Size increased by 20% or more. Special attention should be given to this.
  • ⚠️: Size increased in acceptable range (lower than 20%).
  • ✅: No change or even downsized.
  • 🗑️: The out file is deleted: not found in base branch.
  • 🆕: The out file is newly found: will be added to base branch.

GermanJablo added a commit that referenced this pull request Jun 17, 2026
…ing the document (3.x) (#17034)

## What

Backport of #17030 to the `3.x` branch. Fixes #17016.

On an upload collection with drafts enabled, replacing the file and
clicking **Save draft** on a published document used to:

- flip the main document's `_status` from `published` to `draft`
(effectively unpublishing it), and
- delete the published file from storage.

This only reproduced with **cloud storage adapters** (S3, Azure, GCS,
R2, Vercel Blob, Uploadthing) and only when the file itself was
reuploaded.

## Root cause

The cloud storage `afterChange` hook persists adapter-returned upload
metadata through a nested `payload.update`. Adapters such as S3 return
the full `data` object from `handleUpload`, so `uploadMetadata` was the
entire draft document (including `_status: 'draft'`). Because the nested
update did not preserve the draft state, the draft document was written
onto the **published main row**, unpublishing it. The same hook then
deleted the previous file, which is still referenced by the published
document.

## Fix

In `packages/plugin-cloud-storage/src/hooks/afterChange.ts`:

- Pass the originating draft state (`draft: isDraftSave`) to the nested
`payload.update` so metadata is persisted to the draft version instead
of the published main document.
- Skip deleting the previous file when saving a draft over a published
document (`isDraftOverPublished`), since the published file is still in
use.

Non-draft updates and collections without drafts are unaffected (`draft:
false` matches the prior behavior). The hook in 3.x is structurally
identical to `main`, so the change is the same as #17030.

## Tests

Adds a `draft-with-upload-cloud-storage` collection to the `versions`
suite backed by a mock cloud storage adapter that mirrors the real S3
adapter (its `handleUpload` returns the full `data`). New integration
tests cover:

- Saving a draft with a new file does not unpublish the main document
nor delete its file.
- Publishing the draft afterwards correctly promotes the draft file.
- A normal (non-draft) update still persists adapter metadata to the
main document.

## Notes

- `payload-types.ts` was hand-updated for the new collection (the 3.x
`versions` types have no lexical-node union, unlike `main`). It is
type-only and erased at runtime.
- This was prepared in an isolated worktree without the full monorepo
install, so the integration suite was not run locally; CI runs `test:int
versions`. Static checks done: identical hook diff to #17030, Prettier
clean, and verified the 3.x `cloudStoragePlugin`/`GeneratedAdapter` APIs
match the test wiring.

## Test plan

- `pnpm run test:int versions`

Co-authored-by: German Jablonski <GermanJablo@users.noreply.github.com>
@GermanJablo
GermanJablo merged commit 929aa41 into main Jun 17, 2026
360 of 401 checks passed
@GermanJablo
GermanJablo deleted the fix/cloud-storage-draft-upload-unpublish branch June 17, 2026 16:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Reuploading a file + "Save draft" resets the document's _status from "published" to "draft"

2 participants