From d80869ea3a38fd1f8ee24529188bffeaed2893a3 Mon Sep 17 00:00:00 2001 From: Dave Iannone Date: Thu, 30 Jul 2026 00:00:48 -0400 Subject: [PATCH] fix(ui): re-scope bulkUpload onSuccess by field path to fix hasMany upload collision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BulkUploadProvider held a single onSuccess callback shared by every mounted upload field. Any hasMany upload field's UploadInput reactively re-registered it via setOnSuccess() whenever that field's own value changed, so whichever field's effect last ran "won" ownership of the next upload — not necessarily the field whose Create New button was actually clicked. On a page with more than one hasMany upload field open at once (e.g. a top-level gallery plus per-row galleries inside an array field), uploading through Create New could silently land in a different field's state, making it look like the upload never appended anywhere. This is the same defect class reported in #10177 and fixed for the list-view bulk upload in #10189 (path-keyed onSuccess map + explicit "current active path"), but a later refactor of this provider (Drawer -> Modal rename, plus the parentID/initialForms additions) reverted back to the single-callback shape. This reintroduces the path-keyed map and adds setCurrentActivePath, called imperatively at the moment a field opens the upload modal, so success always resolves against the field that was actually clicked regardless of mount/render order of its siblings. Repro: a collection with a top-level hasMany upload field and a nested array field whose rows each have their own hasMany upload field (e.g. Payload's own docs example of a "gallery" reused inside a "sections" array). With at least one array row present, using Create New on the top-level field silently fails to append once another upload field's effect has run more recently. --- packages/ui/src/elements/BulkUpload/index.tsx | 34 +++++++++++++++---- packages/ui/src/fields/Upload/Input.tsx | 6 +++- packages/ui/src/views/List/index.client.tsx | 20 +++++++++-- 3 files changed, 49 insertions(+), 11 deletions(-) diff --git a/packages/ui/src/elements/BulkUpload/index.tsx b/packages/ui/src/elements/BulkUpload/index.tsx index 3c886523961..540ea008dbe 100644 --- a/packages/ui/src/elements/BulkUpload/index.tsx +++ b/packages/ui/src/elements/BulkUpload/index.tsx @@ -78,10 +78,10 @@ export function BulkUploadModal() { const { modalSlug, onCancel, + setCurrentActivePath, setInitialFiles, setInitialForms, setOnCancel, - setOnSuccess, setParentID, setSelectableCollections, setSuccessfullyUploaded, @@ -116,7 +116,7 @@ export function BulkUploadModal() { setInitialFiles(undefined) setInitialForms(undefined) setOnCancel(() => () => null) - setOnSuccess(() => () => null) + setCurrentActivePath(undefined) setSelectableCollections(null) setSuccessfullyUploaded(false) } @@ -147,6 +147,14 @@ export function BulkUploadModal() { export type BulkUploadContext = { collectionSlug: CollectionSlug + /** + * The field path (or collection slug, for the list view's bulk upload) that currently owns the + * open bulk upload modal. Used to look up the correct entry in the onSuccess map below, so that + * when multiple hasMany upload fields are mounted on the same page (e.g. a top-level gallery + * plus per-row galleries inside an array field), an upload always resolves against the field + * whose "Create New" button was actually clicked — not whichever field's effect last ran. + */ + currentActivePath: string initialFiles: FileList /** * Like initialFiles, but allows manually providing initial form state or the form ID for each file @@ -173,13 +181,14 @@ export type BulkUploadContext = { */ selectableCollections?: null | string[] setCollectionSlug: (slug: string) => void + setCurrentActivePath: (path: string) => void setInitialFiles: (files: FileList) => void setInitialForms: ( forms: ((forms: InitialForms | undefined) => InitialForms | undefined) | InitialForms, ) => void setMaxFiles: (maxFiles: number) => void setOnCancel: (onCancel: BulkUploadContext['onCancel']) => void - setOnSuccess: (onSuccess: BulkUploadContext['onSuccess']) => void + setOnSuccess: (path: string, onSuccess: BulkUploadContext['onSuccess']) => void setParentID: (parentID: number | string | undefined) => void /** * Set the collections that can be selected in the collection dropdown (if applicable) @@ -193,6 +202,7 @@ export type BulkUploadContext = { const Context = React.createContext({ collectionSlug: '', + currentActivePath: undefined, initialFiles: undefined, initialForms: [], maxFiles: undefined, @@ -202,6 +212,7 @@ const Context = React.createContext({ parentID: undefined, selectableCollections: null, setCollectionSlug: () => null, + setCurrentActivePath: () => null, setInitialFiles: () => null, setInitialForms: () => null, setMaxFiles: () => null, @@ -222,7 +233,9 @@ export function BulkUploadProvider({ const [selectableCollections, setSelectableCollections] = React.useState(null) const [collection, setCollection] = React.useState() const [parentID, setParentID] = React.useState(undefined) - const [onSuccessFunction, setOnSuccessFunction] = React.useState() + const [currentActivePath, setCurrentActivePath] = React.useState(undefined) + const [onSuccessFunctionMap, setOnSuccessFunctionMap] = + React.useState>() const [onCancelFunction, setOnCancelFunction] = React.useState() const [initialFiles, setInitialFiles] = React.useState(undefined) const [initialForms, setInitialForms] = React.useState(undefined) @@ -231,9 +244,12 @@ export function BulkUploadProvider({ const modalSlug = `${modalSlugPrefix ? `${modalSlugPrefix}-` : ''}${useBulkUploadModalSlug()}` - const setOnSuccess: BulkUploadContext['setOnSuccess'] = (onSuccess) => { - setOnSuccessFunction(() => onSuccess) - } + const setOnSuccess: BulkUploadContext['setOnSuccess'] = React.useCallback((path, onSuccess) => { + setOnSuccessFunctionMap((prev) => ({ + ...prev, + [path]: onSuccess, + })) + }, []) const setOnCancel: BulkUploadContext['setOnCancel'] = (onCancel) => { setOnCancelFunction(() => onCancel) } @@ -242,6 +258,7 @@ export function BulkUploadProvider({ { + const onSuccessFunction = + currentActivePath !== undefined ? onSuccessFunctionMap?.[currentActivePath] : undefined if (typeof onSuccessFunction === 'function') { onSuccessFunction(newDocs, errorCount) } @@ -259,6 +278,7 @@ export function BulkUploadProvider({ parentID, selectableCollections, setCollectionSlug: setCollection, + setCurrentActivePath, setInitialFiles, setInitialForms, setMaxFiles, diff --git a/packages/ui/src/fields/Upload/Input.tsx b/packages/ui/src/fields/Upload/Input.tsx index dc9d0fbdd16..f33a61603ff 100644 --- a/packages/ui/src/fields/Upload/Input.tsx +++ b/packages/ui/src/fields/Upload/Input.tsx @@ -123,6 +123,7 @@ export function UploadInput(props: UploadInputProps) { const { modalSlug: drawerSlug, setCollectionSlug, + setCurrentActivePath, setInitialFiles, setMaxFiles, setOnSuccess, @@ -394,6 +395,7 @@ export function UploadInput(props: UploadInputProps) { setMaxFiles(maxRows) } + setCurrentActivePath(path) openModal(drawerSlug) }, [ @@ -408,6 +410,8 @@ export function UploadInput(props: UploadInputProps) { setInitialFiles, setSelectableCollections, setMaxFiles, + path, + setCurrentActivePath, ], ) @@ -637,7 +641,7 @@ export function UploadInput(props: UploadInputProps) { }, [populateDocs, value, relationTo]) useEffect(() => { - setOnSuccess(onUploadSuccess) + setOnSuccess(path, onUploadSuccess) }, [value, path, onUploadSuccess, setOnSuccess]) const showDropzone = diff --git a/packages/ui/src/views/List/index.client.tsx b/packages/ui/src/views/List/index.client.tsx index 2620c1ea1ee..41d1483183a 100644 --- a/packages/ui/src/views/List/index.client.tsx +++ b/packages/ui/src/views/List/index.client.tsx @@ -98,7 +98,12 @@ export function DefaultListView(props: ListViewClientProps) { }, [query?.where]) const { openModal } = useModal() - const { modalSlug: bulkUploadModalSlug, setCollectionSlug, setOnSuccess } = useBulkUpload() + const { + modalSlug: bulkUploadModalSlug, + setCollectionSlug, + setCurrentActivePath, + setOnSuccess, + } = useBulkUpload() const collectionConfig = getEntityConfig({ collectionSlug }) @@ -135,9 +140,18 @@ export function DefaultListView(props: ListViewClientProps) { const openBulkUpload = React.useCallback(() => { setCollectionSlug(collectionSlug) + setCurrentActivePath(collectionSlug) openModal(bulkUploadModalSlug) - setOnSuccess(() => router.refresh()) - }, [router, collectionSlug, bulkUploadModalSlug, openModal, setCollectionSlug, setOnSuccess]) + setOnSuccess(collectionSlug, () => router.refresh()) + }, [ + router, + collectionSlug, + bulkUploadModalSlug, + openModal, + setCollectionSlug, + setCurrentActivePath, + setOnSuccess, + ]) useEffect(() => { if (!isInDrawer) {