Search code examples
javascriptreactjsreact-nativepromisecancellation

How do I cancel a repeated promise (Recursive Functions) in React when the component is unmounted?


Hello Everyone

I am using React Native and I wanna adding some feature where the user can import multiple files to my app and the user can cancel the import progress at any time user want

But, when the user import these files I want to import them one by one to tell the user which files have been successfully imported and which files have not been imported,

it's important for me because I want to tell the user how many files that have been selected are successfully imported and also is useful for displaying each file to the UI when that file in progress importing and this requires me to use a Recursive Functions,

The Problem is I have no idea how to cancel a Promise that use a Recursive Functions, I try with makeCancelable method from react site it doesn't work and I think it just only cancel the Promise at top of the tree Recursive Functions, not all executed Promise. Also, I don't wanna use any deps/packages if it's possible. Any Idea?

Core Tools

Using real device Xiaomi Redmi 1S 4.4 Kitkat

"react": "16.13.1",
"react-native": "0.63.3",

Code Sample

importFiles.js

import RNFetchBlob from 'rn-fetch-blob';
import CameraRoll from '@react-native-community/cameraroll';
import _ from 'lodash';

const fs = RNFetchBlob.fs;

/**
 * Import directory destination
 */
const dest = `${fs.dirs.SDCardDir}/VEGA/.src/`;

/**
 * An increment index to tell the function which index to run
 */
let i = 0;

/**
 * Import the files to this App with some encryption
 * @param {object} config
 * @param {string} config.albumId
 * @param {[{
 *  uri: string,
 *  mimeType: string,
 *  albumName: string,
 *  timestamp: number,
 *  isSelected: boolean,
 * }]} config.files
 * @param {'fake' | 'real'=} config.encryptionMode
 */
const importFiles = config => {
  return new Promise(async (resolve, reject) => {
    const {albumId, files, encryptionMode} = config;

    if (_.isEmpty(files) || !_.isArray(files)) {
      reject('invalid files');
      return;
    }

    const file = files[i];

    /**
     * It's mean Done when the file got "undefined"
     */
    if (!file) {
      resolve();
      return;
    }

    const uri = file.uri.replace('file://', '');

    try {
      /**
       * Fake Encryption
       *
       * It's fast but not totally secure
       */
      if (!encryptionMode || encryptionMode === 'fake') {
        const md5 = await fs.hash(uri, 'md5');
        const importedFileUri = `${dest}.${md5}.xml`;

        /**
         * TODO:
         * * Test cancelable
         */
        await fs.readFile(uri, 'base64');
        // await fs.mv(uri, importedFileUri);
        // await CameraRoll.deletePhotos([uri]);

        /**
         * If successfully import this file then continue it to
         * the next index until it's "undefined"
         */
        i++;
      }

      /**
       * Real Encryption
       *
       * It's slow but totally secure
       */
      if (encryptionMode === 'real') {
      }

      await importFiles({files, encryptionMode}).promise;
      resolve();
    } catch (error) {
      reject(error);
    }
  });
};

export default importFiles;

FileImporter.js (How I use makeCancelable method)

import React, {useEffect} from 'react';
import {View, Alert} from 'react-native';
import {Contrainer, TopNavigation, Text} from '../components/Helper';
import {connect} from 'react-redux';
import utils from '../utils';

const makeCancelable = promise => {
  let hasCanceled_ = false;

  const wrappedPromise = new Promise((resolve, reject) => {
    promise.then(
      val => (hasCanceled_ ? reject({isCanceled: true}) : resolve(val)),
      error => (hasCanceled_ ? reject({isCanceled: true}) : reject(error)),
    );
  });

  return {
    promise: wrappedPromise,
    cancel() {
      hasCanceled_ = true;
    },
  };
};

const FileImporter = props => {
  const {userGalleryFiles} = props;

  useEffect(() => {
    props.navigation.addListener('beforeRemove', e => {
      e.preventDefault();

      Alert.alert(
        'Cancel?',
        'Are you sure want to cancel this?',
        [
          {text: 'No', onPress: () => {}},
          {
            text: 'Yes!',
            onPress: () => props.navigation.dispatch(e.data.action),
          },
        ],
        {cancelable: true},
      );
    });

    (async () => {
      const selectedFiles = userGalleryFiles.filter(
        file => file.isSelected === true,
      );

      try {
        await makeCancelable(utils.importFiles({files: selectedFiles})).promise;
        console.warn('Oh God!!!');
      } catch (error) {
        console.error(error);
      }

      return () => makeCancelable().cancel();
    })();
  }, []);

  return (
    <Contrainer>
      <TopNavigation title='Importing files...' disableIconLeft />

      <View style={{flex: 1, justifyContent: 'center', alignItems: 'center'}}>
        <Text hint>0 / 20</Text>
      </View>
    </Contrainer>
  );
};

const mapStateToProps = ({userGalleryFiles}) => ({userGalleryFiles});

export default connect(mapStateToProps)(FileImporter);

Expected Results

importFiles.js can be canceled when FileImporter.js is unmounted

Actual Results

importFiles.js still running even FileImporter.js is unmounted


Solution

  • Try React useEffect({}, [i]) with deps on it instead using Recursive Functions

    import React, {useEffect, useState} from 'react';
    import {View, Alert} from 'react-native';
    import {Contrainer, TopNavigation, Text} from '../components/Helper';
    import {connect} from 'react-redux';
    import utils from '../utils';
    
    const FileImporter = props => {
      const {userGalleryFiles} = props;
      const [currentIndexWantToImport, setCurrentIndexWantToImport] = useState(0)
    
      useEffect(() => {
        (async () => {
          const selectedFiles = userGalleryFiles.filter(
            file => file.isSelected === true,
          );
    
          try {
            await utils.importFiles(selectedFiles[currentIndexWantToImport]);
            setCurrentIndexWantToImport(currentIndexWantToImport++);
            console.warn('Oh God!!!');
          } catch (error) {
            console.error(error);
          }
        })();
      }, [currentIndexWantToImport]);
    
      return (
        <Contrainer>
          <TopNavigation title='Importing files...' disableIconLeft />
    
          <View style={{flex: 1, justifyContent: 'center', alignItems: 'center'}}>
            <Text hint>0 / 20</Text>
          </View>
        </Contrainer>
      );
    };
    
    const mapStateToProps = ({userGalleryFiles}) => ({userGalleryFiles});
    
    export default connect(mapStateToProps)(FileImporter);
    

    Now you have The pure of Recursive Functions from React :)