Good afternoon folks, Here's an issue that's currently driving me up the wall and I cannot seem to see the wood for the trees.
I have a React Native app with a built in file uploader, part of this uploader is the use of the abortcontroller which allows the code to send a signal to the fetch request to stop the call in-flight This works perfectly as one would expect the problem is if the user then selects another file or tries to upload the file the cancelled previously my promise returns instantly with the abort error still in place preventing any further uploads and for love nor money can I seem to find a way to stop this from occurring.
Here is the reduced version of my screen (urls removed, some data points removed, etc) to protect the privacy of my system
import React from 'react';
import {StyleSheet,View,ScrollView,Alert,} from 'react-native';
import AppSettings from '../../constants/AppSettings'
import Colours from '../../constants/Colours';
import CustomHeader from '../../components/CustomHeader';
import CustomButton from '../../components/CustomButton';
import TextContent from '../../components/TextContent';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import Constants from 'expo-constants';
import * as DocumentPicker from 'expo-document-picker';
import * as Permissions from 'expo-permissions';
import CustomInput from '../../components/CustomInput';
import Progressbar from '../../components/ProgressBar';
import * as Network from 'expo-network';
export default class UploadScreen extends React.Component {
state = {
file: null,
filetype: null,
fileExtention:null,
uploading: false,
pickResult: null,
mainDataLoaded: false,
docDesc:null,
enteredPassword:'',
currentUploadPercent:0,
uploadTime: 0,
startPick: false,
};
render() {
return (
<View style={styles.screenContainer}>
<LinearGradient start={[0, 1]} end={[0,0.9]} colors={['rgba(163, 163, 163,1)', 'rgba(163, 163, 163,0)']} style={{flex:1}} >
<ScrollView style={styles.scrollContainer} contentContainerStyle={styles.defaultContainer}>
<View style={{flex:1, alignItems: 'center', justifyContent: 'center', width:'100%' }}>
<TextContent>Upload file containing: {this.state.docDesc}</TextContent>
{this._maybeRenderFile()}
{this._maybeRenderControls()}
{this._maybeRenderUploadingIndicator()}
</View>
</ScrollView>
</LinearGradient>
</View>
);
}
_maybeRenderUploadingIndicator = () => {
if (this.state.uploading) {
return (
<View style={{width:'80%',alignItems:'center'}}>
<Progressbar progress={this.state.currentUploadPercent}/>
<CustomButton style={{width:'100%'}} onPress={()=>{AbortUpload(this)}} title='Cancel Upload'></CustomButton>
</View>
);
}
};
_maybeRenderControls = () => {
if (!this.state.uploading) {
return (
<View style={{width:'100%',alignItems:'center'}}>
<CustomButton style={{width:'80%'}} onPress={this._pickImage} title='Select file to upload'><MaterialCommunityIcons style={{color:Colours.PrimaryButtonText}} name="folder-open" size={30}/></CustomButton>
</View>
);
}
};
_maybeRenderFile = () => {
if (this.state.file) {
switch (this.state.filetype) {
case 'application/pdf':
const passwordHandler = enteredText => {
this.setState({enteredPassword: enteredText});
};
return (
<View style={{alignItems:'center'}}>
<MaterialCommunityIcons style={{color:Colours.PrimaryText}} name="file-pdf" size={100}/>
<TextContent style={{textAlign:'center'}}>File to upload: {this.state.file}</TextContent>
<TextContent>If this file requires a password to access please type it below or leave blank if not required.</TextContent>
{!this.state.uploading && (
<View>
<CustomInput placeholder='PDF Password (if applicable)' autoCapitalize='characters' autoCompleteType='off' autoCorrect={false} textContentType='none' onChangeText={passwordHandler} value={this.state.enteredPassword}/>
<CustomButton style={{width:'100%'}} onPress={()=>{this._handleImagePicked(this.state.pickResult)}} title='Upload this file'><MaterialCommunityIcons style={{color:Colours.PrimaryButtonText}} name="file-upload-outline" size={30}/></CustomButton>
<TextContent style={{textAlign:'center'}}>Or</TextContent>
</View>
)}
</View>
);
break;
case 'image/jpg':
case 'image/png':
case 'image/gif':
return (
<View style={{alignItems:'center'}}>
<MaterialCommunityIcons style={{color:Colours.PrimaryText}} name="file-image" size={100}/>
<TextContent style={{textAlign:'center'}}>File to upload: {this.state.file}</TextContent>
{!this.state.uploading && (
<View>
<CustomButton style={{minWidth:'80%'}} onPress={()=>{this._handleImagePicked(this.state.pickResult)}} title='Upload this file'><MaterialCommunityIcons style={{color:Colours.PrimaryButtonText}} name="file-upload-outline" size={30}/></CustomButton>
<TextContent style={{textAlign:'center'}}>Or</TextContent>
</View>
)}
</View>
);
break;
default:
break;
}
}
};
_askPermission = async (type, failureMessage) => {
const { status, permissions } = await Permissions.askAsync(type);
if (status === 'denied') {
alert(failureMessage);
}
};
_pickImage = async () => {
await this._askPermission(
Permissions.CAMERA_ROLL,
'We need the file permission to access files from your phone...'
);
if(!this.state.startPick){
this.setState({startPick: true})
let pickerResult = await DocumentPicker.getDocumentAsync({});
if(pickerResult.type == 'success'){
this.setState({startPick: false})
//Get file extention
var splitAt = pickerResult.name.lastIndexOf(".")
var fileExt = pickerResult.name.slice(splitAt,pickerResult.name.length).toLowerCase()
switch (fileExt) {
case '.pdf':
this.setState({file: pickerResult.name, filetype: 'application/pdf', pickResult: pickerResult, fileExtention: fileExt})
break;
case '.jpg':
this.setState({file: pickerResult.name, filetype: 'image/jpg', pickResult: pickerResult, fileExtention: fileExt})
break;
case '.jpeg':
this.setState({file: pickerResult.name, filetype: 'image/jpg', pickResult: pickerResult, fileExtention: fileExt})
break;
case '.png':
this.setState({file: pickerResult.name, filetype: 'image/png', pickResult: pickerResult, fileExtention: fileExt})
break;
case '.gif':
this.setState({file: pickerResult.name, filetype: 'image/gif', pickResult: pickerResult, fileExtention: fileExt})
break;
default:
this.setState({file: null, filetype: null, pickResult: null})
Alert.alert('Unsupported filetype','For security reasons you may only select images or PDF files to upload.')
break;
}
}else{
//No file selected
this.setState({file: null, filetype: null, pickResult: null})
this.setState({startPick: false})
}
if(__DEV__){console.log('Result:', pickerResult)}
}else{
if(__DEV__){console.log('Pick already started')}
}
};
_StatusCheck = async() =>{
return fetch('Url for server side upload status response')
.then((response) => response.json())
.then((responseJson) => {
return responseJson
})
.catch((error) =>{
console.error(error);
});
}
_handleImagePicked = async pickerResult => {
try {
if (!pickerResult.cancelled) {
var thisTime = Date.now()
this.setState({uploadTime: thisTime})
var myPromise = new Promise(function(){})
myPromise = MakeQuerablePromise(new uploadFileAsync(
pickerResult.uri,
this.state.docType + '-' + Date.now() + this.state.fileExtention,
this.state.filetype,
this.state.docType,
this.state.docDesc,
this.state.enteredPassword,
this.state.quoteID,
this.state.uploading,
thisTime,
controller.signal
));
this.setState({ uploading: true });
if(__DEV__){
console.log("Initial fulfilled:", myPromise.isFulfilled());//false
console.log("Initial rejected:", myPromise.isRejected());//false
console.log("Initial pending:", myPromise.isPending());//true
}
for (let index = 0; index < 1;) {
var currentStatus = await this._StatusCheck()
var curTime = new Date()
if(__DEV__){
console.log('Time:',curTime.getHours(),':',curTime.getMinutes(),':',curTime.getSeconds())
console.log('Status:',currentStatus)
console.log("Promise status- fulfilled:", myPromise.isFulfilled(), "rejected:", myPromise.isRejected(),"pending:", myPromise.isPending());//false
console.log('Promise Content:',myPromise)
}
if(!myPromise.isRejected()){
if(currentStatus.percent != undefined){
if(currentStatus.percent < 100){
this.setState({currentUploadPercent:currentStatus.percent})
if(__DEV__){
console.log('Upload progess ' + currentStatus.percent)
console.log("Promise status: fulfilled:", myPromise.isFulfilled(), "rejected:", myPromise.isRejected(),"pending:", myPromise.isPending());//false
}
}else{
this.setState({currentUploadPercent:currentStatus.percent})
if(__DEV__){
console.log('Upload progess 100%')
console.log("Promise status: fulfilled:", myPromise.isFulfilled(), "rejected:", myPromise.isRejected(),"pending:", myPromise.isPending());//false
}
}
}
}
if(myPromise.isFulfilled() == true){
if(__DEV__){
console.log("Entered Fulfilled State - Promise status: fulfilled:", myPromise.isFulfilled(), "rejected:", myPromise.isRejected(),"pending:", myPromise.isPending());//false
}
index++
}
if(myPromise.isRejected() == true){
if(__DEV__){
console.log("Entered Rejected State - Promise status: fulfilled:", myPromise.isFulfilled(), "rejected:", myPromise.isRejected(),"pending:", myPromise.isPending());//false
}
index++
}
}
if(myPromise.isRejected() == false){
myPromise.then(response => response.json()).then((responseJson)=>{
if(__DEV__){
console.log('Promise Json:',responseJson)
console.log("Final fulfilled:", myPromise.isFulfilled());//true
console.log("Final rejected:", myPromise.isRejected());//false
console.log("Final pending:", myPromise.isPending());//false
}
if(responseJson.datapage.result.gitUploadStatus.successful == true){
//Successful upload
this.props.navigation.navigate('CaptureThanks')
}else{
//Upload had a issue
Alert.alert('Upload error','There was an issue with the upload, this maybe a temporary issue with your connection or with the server please ensure you have a good steady signal and try again, if the problem persists please contact us. Error Code: '+responseJson.datapage.gitUploadStatus.errorMessage)
}
})
}else{
//Rejected promise handle failure
if(myPromise.rejectReason() == 'AbortError'){
myPromise = MakeQuerablePromise(new Promise(function(resolve, reject){
resolve('AbortError')
}))
}else{
Alert.alert('Upload error','There was an issue with the upload, this maybe a temporary issue with your connection or with the server please ensure you have a good steady signal and try again, if the problem persists please contact us. Error Code: '+responseJson.datapage.gitUploadStatus.errorMessage)
}
}
}
} catch (e) {
if(__DEV__){
console.log('Error Name:',e.name)
console.log('Catch Error:',{ e });
}
return;
} finally {
if(__DEV__){
console.log('Reached Final')
}
myPromise = MakeQuerablePromise(new Promise(function(resolve, reject){
resolve('Finished')
}))
console.log(myPromise)
this.setState({ uploading: false, currentUploadPercent:0 });
}
};
}
function MakeQuerablePromise(promise) {
// Don't modify any promise that has been already modified.
if (promise.isResolved){
return promise
};
// Set initial state
var isPending = true;
var isRejected = false;
var isFulfilled = false;
var rejectReason = '';
// Observe the promise, saving the fulfillment in a closure scope.
var result = promise.then(
function(v) {
isFulfilled = true;
isPending = false;
rejectReason = '';
return v;
},
function(e) {
isRejected = true;
isPending = false;
rejectReason = e.name;
return e;
}
);
result.isFulfilled = function() { return isFulfilled; };
result.isPending = function() { return isPending; };
result.isRejected = function() { return isRejected; };
result.rejectReason = function() {return rejectReason; };
return result;
}
const controller = new AbortController;
async function uploadFileAsync(uri,name,type,docType,docDesc,password,quoteid,isUploading,uploadTime,abortSignal) {
if(!isUploading){
if(__DEV__){console.log('Making upload request for ',docType,' Document description:', docDesc)}
let apiUrl = 'Url to push the upload to';
let formData = new FormData();
//(method) FormData.append(name: string, value: string | Blob, fileName?: string): void
formData.append('filename', {
uri,
name: name,
type: type,
documentType:docType,
description:docDesc,
password:password,
quoteid:quoteid,
});
let options = {
method: 'POST',
body: formData,
headers: {
Accept: 'application/json',
'Content-Type': 'multipart/form-data',
},
signal: abortSignal,
};
if(__DEV__){console.log('Options:', options)}
return fetch(apiUrl, options);
}else{
return null
}
}
async function AbortUpload(stateObject){
controller.abort()
stateObject.setState({isUploading: false})
}
UploadScreen.navigationOptions = {
header: () => <CustomHeader goback={true} title='Document Upload'/>,
title: AppSettings.AppName + ' ',
headerTitleStyle:{
fontFamily: AppSettings.HeaderFont,
},
headerStyle: {
backgroundColor: Colours.HeaderBackground
},
headerTintColor: Colours.HeaderText
};
const styles = StyleSheet.create({
screenContainer:{
flex:1,
backgroundColor: Colours.PrimaryBackgroud,
},
scrollContainer:{
flex: 1,
height:'100%'
},
defaultContainer: {
alignItems: 'center',
},
});
Forgive the nature of my code as I'm still pretty new to react native and only been doing it for a few months now so still getting my head around a lot of the features and systems and only just updated to the latest version of expo (36) yesterday so I could enable the fetch abort.
But it anyone has any clues as to why it seems that after the signal is called once to abort every future request then seems to be getting that same signal regardless that the user has not clicked it again and I am not storing it in state so I can't seem to understand why its persisting to the degree that I even went to the extreme of rebuilding the promises at start and end to ensure they were cleaned every time the upload is fired.
You shared way too much code, but I'll have a try at this.
First of all, I'm surprised it even worked in the first place. You're creating your abort controller like so:
const controller = new AbortController;
While it should be:
const controller = new AbortController();
Now, when it comes to the logic, I believe it's displaying an error because you're using that same controller, which was already canceled.
The trick would be to turn it into a state and update it with a new AbortController
as soon as it gets aborted (probably in your AbortUpload
method, right after calling controller.abort()
).