From efed4d3721674d2a2361be1fadfbdd5a54c014d1 Mon Sep 17 00:00:00 2001
From: Ben Wheeler <wheeler.benjamin@gmail.com>
Date: Tue, 28 Jan 2020 14:38:20 -0500
Subject: [PATCH] file input is dynamically added to DOM, not rendered

---
 src/lib/sb-file-uploader-hoc.jsx | 144 ++++++++++++++++++-------------
 1 file changed, 86 insertions(+), 58 deletions(-)

diff --git a/src/lib/sb-file-uploader-hoc.jsx b/src/lib/sb-file-uploader-hoc.jsx
index 90dd1ff53..78f68646c 100644
--- a/src/lib/sb-file-uploader-hoc.jsx
+++ b/src/lib/sb-file-uploader-hoc.jsx
@@ -43,47 +43,48 @@ const SBFileUploaderHOC = function (WrappedComponent) {
         constructor (props) {
             super(props);
             bindAll(this, [
+                'createFileObjects',
                 'getProjectTitleFromFilename',
+                'handleFinishedLoadingUpload',
                 'handleStartSelectingFileUpload',
-                'setFileInput',
                 'handleChange',
                 'onload',
-                'resetFileInput'
+                'removeFileObjects'
             ]);
         }
-        componentWillMount () {
-            this.reader = new FileReader();
-            this.reader.onload = this.onload;
-            this.resetFileInput();
-        }
         componentDidUpdate (prevProps) {
             if (this.props.isLoadingUpload && !prevProps.isLoadingUpload) {
-                if (this.fileToUpload && this.reader) {
-                    this.reader.readAsArrayBuffer(this.fileToUpload);
-                } else {
-                    this.props.cancelFileUpload(this.props.loadingState);
-                }
+                this.handleFinishedLoadingUpload(); // cue step 5 below
             }
         }
         componentWillUnmount () {
-            this.reader = null;
-            this.resetFileInput();
+            this.removeFileObjects();
         }
-        resetFileInput () {
-            this.fileToUpload = null;
-            if (this.fileInput) {
-                this.fileInput.value = null;
-            }
+        // step 1: this is where the upload process begins
+        handleStartSelectingFileUpload () {
+            this.createFileObjects(); // go to step 2
         }
-        getProjectTitleFromFilename (fileInputFilename) {
-            if (!fileInputFilename) return '';
-            // only parse title with valid scratch project extensions
-            // (.sb, .sb2, and .sb3)
-            const matches = fileInputFilename.match(/^(.*)\.sb[23]?$/);
-            if (!matches) return '';
-            return matches[1].substring(0, 100); // truncate project title to max 100 chars
+        // step 2: create a FileReader and an <input> element, and issue a
+        // pseudo-click to it. That will open the file chooser dialog.
+        createFileObjects () {
+            // redo step 7, in case it got skipped last time and its objects are
+            // still in memory
+            this.removeFileObjects();
+            // create fileReader
+            this.fileReader = new FileReader();
+            this.fileReader.onload = this.onload;
+            // create <input> element and add it to DOM
+            this.inputElement = document.createElement('input');
+            this.inputElement.accept = '.sb,.sb2,.sb3';
+            this.inputElement.style = 'display: none;';
+            this.inputElement.type = 'file';
+            this.inputElement.onchange = this.handleChange; // connects to step 3
+            document.body.appendChild(this.inputElement);
+            // simulate a click to open file chooser dialog
+            this.inputElement.click();
         }
-        // called when user has finished selecting a file to upload
+        // step 3: user has picked a file using the file chooser dialog.
+        // We don't actually load the file here, we only decide whether to do so.
         handleChange (e) {
             const {
                 intl,
@@ -92,14 +93,14 @@ const SBFileUploaderHOC = function (WrappedComponent) {
                 projectChanged,
                 userOwnsProject
             } = this.props;
-
             const thisFileInput = e.target;
             if (thisFileInput.files) { // Don't attempt to load if no file was selected
                 this.fileToUpload = thisFileInput.files[0];
 
                 // If user owns the project, or user has changed the project,
-                // we must confirm with the user that they really intend to replace it.
-                // (If they don't own the project and haven't changed it, no need to confirm.)
+                // we must confirm with the user that they really intend to
+                // replace it. (If they don't own the project and haven't
+                // changed it, no need to confirm.)
                 let uploadAllowed = true;
                 if (userOwnsProject || (projectChanged && isShowingWithoutId)) {
                     uploadAllowed = confirm( // eslint-disable-line no-alert
@@ -107,58 +108,80 @@ const SBFileUploaderHOC = function (WrappedComponent) {
                     );
                 }
                 if (uploadAllowed) {
+                    // cues step 4
                     this.props.requestProjectUpload(loadingState);
                 } else {
-                    this.resetFileInput();
+                    // skips ahead to step 7
+                    this.removeFileObjects();
                 }
                 this.props.closeFileMenu();
             }
         }
-        // called when file upload raw data is available in the reader
+        // step 4 is below, in mapDispatchToProps
+
+        // step 5: called from componentDidUpdate when project state shows
+        // that project data has finished "uploading" into the browser
+        handleFinishedLoadingUpload () {
+            if (this.fileToUpload && this.fileReader) {
+                // begin to read data from the file. When finished,
+                // cues step 6 using the reader's onload callback
+                this.fileReader.readAsArrayBuffer(this.fileToUpload);
+            } else {
+                this.props.cancelFileUpload(this.props.loadingState);
+                // skip ahead to step 7
+                this.removeFileObjects();
+            }
+        }
+        // used in step 6 below
+        getProjectTitleFromFilename (fileInputFilename) {
+            if (!fileInputFilename) return '';
+            // only parse title with valid scratch project extensions
+            // (.sb, .sb2, and .sb3)
+            const matches = fileInputFilename.match(/^(.*)\.sb[23]?$/);
+            if (!matches) return '';
+            return matches[1].substring(0, 100); // truncate project title to max 100 chars
+        }
+        // step 6: attached as a handler on our FileReader object; called when
+        // file upload raw data is available in the reader
         onload () {
-            if (this.reader) {
+            if (this.fileReader) {
                 this.props.onLoadingStarted();
                 const filename = this.fileToUpload && this.fileToUpload.name;
-                this.props.vm.loadProject(this.reader.result)
+                this.props.vm.loadProject(this.fileReader.result)
                     .then(() => {
                         this.props.onLoadingFinished(this.props.loadingState, true);
-                        // Reset the file input after project is loaded
-                        // This is necessary in case the user wants to reload a project
                         if (filename) {
                             const uploadedProjectTitle = this.getProjectTitleFromFilename(filename);
                             this.props.onUpdateProjectTitle(uploadedProjectTitle);
                         }
-                        this.resetFileInput();
                     })
                     .catch(error => {
                         log.warn(error);
                         this.props.intl.formatMessage(messages.loadError); // eslint-disable-line no-alert
                         this.props.onLoadingFinished(this.props.loadingState, false);
-                        // Reset the file input after project is loaded
-                        // This is necessary in case the user wants to reload a project
-                        this.resetFileInput();
+                    })
+                    .then(() => {
+                        // go back to step 7: whether project loading succeeded
+                        // or failed, reset file objects
+                        this.removeFileObjects();
                     });
             }
         }
-        handleStartSelectingFileUpload () {
-            // open filesystem browsing window
-            this.fileInput.click();
-        }
-        setFileInput (input) {
-            this.fileInput = input;
+        // step 7: remove the <input> element from the DOM and clear reader and
+        // fileToUpload reference, so those objects can be garbage collected
+        removeFileObjects () {
+            if (this.inputElement) {
+                this.inputElement.value = null;
+                document.body.removeChild(this.inputElement);
+            }
+            this.inputElement = null;
+            this.fileReader = null;
+            this.fileToUpload = null;
         }
         render () {
-            const fileInput = (
-                <input
-                    accept=".sb,.sb2,.sb3"
-                    ref={this.setFileInput}
-                    style={{display: 'none'}}
-                    type="file"
-                    onChange={this.handleChange}
-                />
-            );
             const {
                 /* eslint-disable no-unused-vars */
+                cancelFileUpload,
                 closeFileMenu: closeFileMenuProp,
                 isLoadingUpload,
                 isShowingWithoutId,
@@ -177,7 +200,6 @@ const SBFileUploaderHOC = function (WrappedComponent) {
                         onStartSelectingFileUpload={this.handleStartSelectingFileUpload}
                         {...componentProps}
                     />
-                    {fileInput}
                 </React.Fragment>
             );
         }
@@ -210,19 +232,25 @@ const SBFileUploaderHOC = function (WrappedComponent) {
             loadingState: loadingState,
             projectChanged: state.scratchGui.projectChanged,
             userOwnsProject: ownProps.authorUsername && user &&
-                (ownProps.authorUsername === user.username)
-            // vm: state.scratchGui.vm
+                (ownProps.authorUsername === user.username),
+            vm: state.scratchGui.vm // NOTE: double check this belongs here
         };
     };
     const mapDispatchToProps = (dispatch, ownProps) => ({
         cancelFileUpload: loadingState => dispatch(onLoadedProject(loadingState, false, false)),
         closeFileMenu: () => dispatch(closeFileMenu()),
+        // transition project state from loading to regular, and close
+        // loading screen and file menu
         onLoadingFinished: (loadingState, success) => {
             dispatch(onLoadedProject(loadingState, ownProps.canSave, success));
             dispatch(closeLoadingProject());
             dispatch(closeFileMenu());
         },
+        // show project loading screen
         onLoadingStarted: () => dispatch(openLoadingProject()),
+        // step 4: transition the project state so we're ready to handle the new
+        // project data. When this is done, the project state transition will be
+        // noticed by componentDidUpdate()
         requestProjectUpload: loadingState => dispatch(requestProjectUpload(loadingState))
     });
     // Allow incoming props to override redux-provided props. Used to mock in tests.
-- 
GitLab