Fix allowMultipleUploadBatches by preventing file operations during upload (#6156)

## Implementation Plan for Transloadit allowMultipleUploadBatches Fix

- [x] Explore the codebase to understand existing structure
  - [x] Review transloadit/src/index.ts
  - [x] Review existing event handlers (#onError, #onCancelAll)
  - [x] Review install() and uninstall() methods
  - [x] Understand test structure
- [x] Implement the proper fix in
packages/@uppy/transloadit/src/index.ts
- [x] Set allowNewUpload: false at start of #prepareUpload
(preprocessor)
- [x] Reset allowNewUpload: true at end of #afterUpload (postprocessor)
  - [x] Reset allowNewUpload: true in #prepareUpload error handler
  - [x] Keep existing resets in #onError and #onCancelAll handlers
  - [x] Removed event listener approach (was causing race conditions)
- [x] Update tests to match implementation
- [x] Test allowNewUpload lifecycle through actual upload flow
(preprocessor/postprocessor)
  - [x] Test allowNewUpload reset on preprocessor error
  - [x] Test allowNewUpload reset on error event
  - [x] Test allowNewUpload reset on cancel-all
  - [x] Remove obsolete event listener tests

<!-- START COPILOT CODING AGENT TIPS -->
---

💡 You can make Copilot smarter by setting up custom instructions,
customizing its development environment and configuring Model Context
Protocol (MCP) servers. Learn more [Copilot coding agent
tips](https://gh.io/copilot-coding-agent-tips) in the docs.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: mifi <402547+mifi@users.noreply.github.com>
Co-authored-by: Mikael Finstad <finstaden@gmail.com>
This commit is contained in:
Copilot 2026-02-09 21:42:43 +08:00 committed by GitHub
parent 49db42d72a
commit f6efd2ebcc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 214 additions and 29 deletions

View file

@ -77,37 +77,99 @@ describe('Transloadit', () => {
})
it('should complete when resuming after pause', async () => {
let firstUploadCallCount = 0
const assemblyStatusBase = {
assembly_id: 'test-assembly-id',
websocket_url: 'ws://localhost:8080',
tus_url: 'http://localhost/resumable/files/',
assembly_ssl_url:
'https://api2.transloadit.com/assemblies/test-assembly-id',
}
const tusUploads = new Map()
let uploadIndex = 0
const tusBaseUrl = 'http://localhost/resumable/files/'
const server = setupServer(
http.post('*/assemblies', ({ request }) => {
http.options('http://localhost/resumable/files*', () => {
return new HttpResponse(null, {
status: 204,
headers: {
'Tus-Resumable': '1.0.0',
'Tus-Version': '1.0.0',
'Tus-Extension': 'creation,creation-defer-length',
},
})
}),
http.post('http://localhost/resumable/files*', ({ request }) => {
const uploadLengthHeader = request.headers.get('upload-length')
const uploadLength = uploadLengthHeader ? Number(uploadLengthHeader) : 0
const uploadId = `test-upload-${uploadIndex++}`
tusUploads.set(uploadId, {
length: Number.isNaN(uploadLength) ? 0 : uploadLength,
offset: 0,
})
return new HttpResponse(null, {
status: 201,
headers: {
Location: `${tusBaseUrl}${uploadId}`,
'Upload-Offset': '0',
'Tus-Resumable': '1.0.0',
},
})
}),
http.head('http://localhost/resumable/files/:uploadId', ({ params }) => {
const upload = tusUploads.get(params.uploadId)
if (!upload) {
return new HttpResponse(null, { status: 404 })
}
return new HttpResponse(null, {
status: 200,
headers: {
'Upload-Offset': String(upload.offset),
'Upload-Length': String(upload.length),
'Tus-Resumable': '1.0.0',
},
})
}),
http.patch(
'http://localhost/resumable/files/:uploadId',
async ({ request, params }) => {
const upload = tusUploads.get(params.uploadId)
if (!upload) {
return new HttpResponse(null, { status: 404 })
}
if (upload.offset === 0) {
await new Promise((resolve) => setTimeout(resolve, 200))
}
const body = await request.arrayBuffer()
const offsetHeader = request.headers.get('upload-offset')
const baseOffset = offsetHeader ? Number(offsetHeader) : upload.offset
const nextOffset = baseOffset + body.byteLength
upload.offset = nextOffset
return new HttpResponse(null, {
status: 204,
headers: {
'Upload-Offset': String(nextOffset),
'Tus-Resumable': '1.0.0',
},
})
},
),
http.post('https://api2.transloadit.com/assemblies', ({ request }) => {
return HttpResponse.json({
assembly_id: 'test-assembly-id',
websocket_url: 'ws://localhost:8080',
tus_url: 'https://localhost/resumable/files/',
assembly_ssl_url: 'https://localhost/assemblies/test-assembly-id',
...assemblyStatusBase,
ok: 'ASSEMBLY_EXECUTING',
})
}),
http.get('*/assemblies/*', () => {
http.get('https://api2.transloadit.com/assemblies/*', () => {
return HttpResponse.json({
assembly_id: 'test-assembly-id',
...assemblyStatusBase,
ok: 'ASSEMBLY_COMPLETED',
results: {},
})
}),
http.post('*/resumable/files/', () => {
firstUploadCallCount++
return HttpResponse.json({
tus_enabled: true,
resumable_file_id: `test-file-id-${firstUploadCallCount}`,
})
}),
http.patch('*/resumable/files/*', () => {
return HttpResponse.json({
ok: 'RESUMABLE_FILE_UPLOADED',
})
}),
http.post('https://transloaditstatus.com/client_error', () => {
return HttpResponse.json({})
}),
@ -130,21 +192,129 @@ describe('Transloadit', () => {
uppy.addFile({
source: 'test',
name: 'cat.jpg',
data: Buffer.from('test file content'),
data: new File([new Uint8Array([1, 2, 3, 4, 5])], 'cat.jpg', {
type: 'image/jpeg',
}),
})
uppy.addFile({
source: 'test',
name: 'traffic.jpg',
data: Buffer.from('test file content 2'),
data: new File([new Uint8Array([6, 7, 8, 9, 10, 11])], 'traffic.jpg', {
type: 'image/jpeg',
}),
})
uppy.upload()
// Initially should be true
expect(uppy.getState().allowNewUpload).toBe(true)
const uploadPromise = uppy.upload()
// Should be set to false during upload
expect(uppy.getState().allowNewUpload).toBe(false)
// Trying to add a new file during upload with Transloadit should not be possible
expect(() =>
uppy.addFile({
source: 'test',
name: 'additionalFile.jpg',
data: new File([new Uint8Array([0])], 'additionalFile.jpg', {
type: 'image/jpeg',
}),
}),
).toThrowError('Cannot add more files')
await new Promise((resolve) => setTimeout(resolve, 100))
uppy.pauseAll()
await uppy.upload()
uppy.resumeAll()
await uploadPromise
expect(successSpy).toHaveBeenCalled()
// Should be reset to true after upload completes
expect(uppy.getState().allowNewUpload).toBe(true)
server.close()
})
it('resets allowNewUpload to true on preprocessor error', async () => {
const uppy = new Core()
uppy.use(Transloadit, {
assemblyOptions: {
params: {
auth: { key: 'test-auth-key' },
template_id: 'test-template-id',
},
},
})
// Mock createAssembly to throw an error
uppy.getPlugin('Transloadit').client.createAssembly = () =>
Promise.reject(new Error('Assembly creation failed'))
uppy.addFile({
source: 'test',
name: 'test.jpg',
data: Buffer.from('test file content'),
})
// Initially should be true
expect(uppy.getState().allowNewUpload).toBe(true)
try {
await uppy.upload()
} catch {
// Expected to fail
}
// Should be reset to true after error
expect(uppy.getState().allowNewUpload).toBe(true)
})
it('resets allowNewUpload to true on cancel-all', async () => {
const uppy = new Core()
uppy.use(Transloadit, {
assemblyOptions: {
params: {
auth: { key: 'test-auth-key' },
template_id: 'test-template-id',
},
},
})
// Manually set allowNewUpload to false to simulate an upload in progress
uppy.setState({ allowNewUpload: false })
expect(uppy.getState().allowNewUpload).toBe(false)
// Simulate cancel-all
uppy.cancelAll()
// Should be reset to true
expect(uppy.getState().allowNewUpload).toBe(true)
})
it('resets allowNewUpload to true on error event', () => {
const uppy = new Core()
uppy.use(Transloadit, {
assemblyOptions: {
params: {
auth: { key: 'test-auth-key' },
template_id: 'test-template-id',
},
},
})
// Manually set allowNewUpload to false to simulate an upload in progress
uppy.setState({ allowNewUpload: false })
expect(uppy.getState().allowNewUpload).toBe(false)
// Trigger error event
uppy.emit('error', {
name: 'TestError',
message: 'Test error message',
})
// Should be reset to true
expect(uppy.getState().allowNewUpload).toBe(true)
})
})

View file

@ -639,12 +639,15 @@ export default class Transloadit<
* When all files are removed, cancel in-progress Assemblies.
*/
#onCancelAll = async () => {
if (!this.assembly) return
try {
await this.#cancelAssembly(this.assembly.status)
} catch (err) {
this.uppy.log(err)
if (this.assembly) {
try {
await this.#cancelAssembly(this.assembly.status)
} catch (err) {
this.uppy.log(err)
}
}
// Reset allowNewUpload when upload is cancelled
this.uppy.setState({ allowNewUpload: true })
}
#onRestored = (pluginData: Record<string, PersistentState>) => {
@ -813,6 +816,10 @@ export default class Transloadit<
}
#prepareUpload = async (fileIDs: string[]) => {
// Prevent adding/dropping files during upload to avoid creating multiple assemblies
// TODO we should rewrite to instead infer allowNewUpload based on upload state
this.uppy.setState({ allowNewUpload: false })
const assemblyOptions = (
typeof this.opts.assemblyOptions === 'function'
? await this.opts.assemblyOptions()
@ -850,6 +857,8 @@ export default class Transloadit<
this.uppy.emit('preprocess-complete', file)
this.uppy.emit('upload-error', file, err)
})
// Reset allowNewUpload on error
this.uppy.setState({ allowNewUpload: true })
throw err
}
}
@ -916,6 +925,8 @@ export default class Transloadit<
// we need to allow a new assembly to be created.
// see https://github.com/transloadit/uppy/issues/5397
this.assembly = undefined
// Allow new uploads now that this upload is complete
this.uppy.setState({ allowNewUpload: true })
}
}
@ -931,6 +942,9 @@ export default class Transloadit<
.submitError(err)
// if we can't report the error that sucks
.catch(sendErrorToConsole(err))
// Reset allowNewUpload when upload encounters an error
this.uppy.setState({ allowNewUpload: true })
}
#onTusError = (_: UppyFile<M, B> | undefined, err: Error) => {
@ -1008,6 +1022,7 @@ export default class Transloadit<
this.uppy.removePreProcessor(this.#prepareUpload)
this.uppy.removePostProcessor(this.#afterUpload)
this.uppy.off('error', this.#onError)
this.uppy.off('cancel-all', this.#onCancelAll)
if (this.opts.importFromUploadURLs) {
this.uppy.off('upload-success', this.#onFileUploadURLAvailable)