diff --git a/packages/react-native/Libraries/Blob/FileReader.js b/packages/react-native/Libraries/Blob/FileReader.js index 3745c00923a8..98e7f8c9da15 100644 --- a/packages/react-native/Libraries/Blob/FileReader.js +++ b/packages/react-native/Libraries/Blob/FileReader.js @@ -17,6 +17,7 @@ import { setEventHandlerAttribute, } from '../../src/private/webapis/dom/events/EventHandlerAttributes'; import EventTarget from '../../src/private/webapis/dom/events/EventTarget'; +import DOMException from '../../src/private/webapis/errors/DOMException'; import NativeFileReaderModule from './NativeFileReaderModule'; import {toByteArray} from 'base64-js'; @@ -41,9 +42,10 @@ class FileReader extends EventTarget { DONE: number = DONE; _readyState: ReadyState; - _error: ?Error; + _error: ?DOMException; _result: ?ReaderResult; _aborted: boolean = false; + _readId: number = 0; constructor() { super(); @@ -56,10 +58,27 @@ class FileReader extends EventTarget { this._result = null; } + _startRead(methodName: string): number { + if (this._readyState === LOADING) { + throw new DOMException( + `Failed to execute '${methodName}' on 'FileReader': The object is already busy reading Blobs.`, + 'InvalidStateError', + ); + } + this._aborted = false; + this._error = null; + this._result = null; + const readId = ++this._readId; + this._setReadyState(LOADING); + return readId; + } + _setReadyState(newState: ReadyState) { this._readyState = newState; this.dispatchEvent(new Event('readystatechange')); - if (newState === DONE) { + if (newState === LOADING) { + this.dispatchEvent(new Event('loadstart')); + } else if (newState === DONE) { if (this._aborted) { this.dispatchEvent(new Event('abort')); } else if (this._error) { @@ -67,24 +86,34 @@ class FileReader extends EventTarget { } else { this.dispatchEvent(new Event('load')); } - this.dispatchEvent(new Event('loadend')); + if (this._readyState !== LOADING) { + this.dispatchEvent(new Event('loadend')); + } } } - readAsArrayBuffer(blob: ?Blob): void { - this._aborted = false; + _toDOMException(error: unknown): DOMException { + if (error instanceof DOMException) { + return error; + } + if (error instanceof Error) { + return new DOMException(error.message, 'NotReadableError'); + } + return new DOMException(String(error), 'NotReadableError'); + } + readAsArrayBuffer(blob: ?Blob): void { if (blob == null) { throw new TypeError( "Failed to execute 'readAsArrayBuffer' on 'FileReader': parameter 1 is not of type 'Blob'", ); } - this._setReadyState(LOADING); + const readId = this._startRead('readAsArrayBuffer'); NativeFileReaderModule.readAsDataURL(blob.data).then( (text: string) => { - if (this._aborted) { + if (readId !== this._readId) { return; } @@ -95,89 +124,83 @@ class FileReader extends EventTarget { this._setReadyState(DONE); }, error => { - if (this._aborted) { + if (readId !== this._readId) { return; } - this._error = error; + this._error = this._toDOMException(error); this._setReadyState(DONE); }, ); } readAsDataURL(blob: ?Blob): void { - this._aborted = false; - if (blob == null) { throw new TypeError( "Failed to execute 'readAsDataURL' on 'FileReader': parameter 1 is not of type 'Blob'", ); } - this._setReadyState(LOADING); + const readId = this._startRead('readAsDataURL'); NativeFileReaderModule.readAsDataURL(blob.data).then( (text: string) => { - if (this._aborted) { + if (readId !== this._readId) { return; } this._result = text; this._setReadyState(DONE); }, error => { - if (this._aborted) { + if (readId !== this._readId) { return; } - this._error = error; + this._error = this._toDOMException(error); this._setReadyState(DONE); }, ); } readAsText(blob: ?Blob, encoding: string = 'UTF-8'): void { - this._aborted = false; - if (blob == null) { throw new TypeError( "Failed to execute 'readAsText' on 'FileReader': parameter 1 is not of type 'Blob'", ); } - this._setReadyState(LOADING); + const readId = this._startRead('readAsText'); NativeFileReaderModule.readAsText(blob.data, encoding).then( (text: string) => { - if (this._aborted) { + if (readId !== this._readId) { return; } this._result = text; this._setReadyState(DONE); }, error => { - if (this._aborted) { + if (readId !== this._readId) { return; } - this._error = error; + this._error = this._toDOMException(error); this._setReadyState(DONE); }, ); } abort() { - this._aborted = true; - // only call onreadystatechange if there is something to abort, as per spec - if (this._readyState !== EMPTY && this._readyState !== DONE) { - this._reset(); + this._result = null; + if (this._readyState === LOADING) { + this._aborted = true; + this._readId++; this._setReadyState(DONE); } - // Reset again after, in case modified in handler - this._reset(); } get readyState(): ReadyState { return this._readyState; } - get error(): ?Error { + get error(): ?DOMException { return this._error; } diff --git a/packages/react-native/Libraries/Blob/__tests__/FileReader-test.js b/packages/react-native/Libraries/Blob/__tests__/FileReader-test.js index 36d55849c487..69d02080ab94 100644 --- a/packages/react-native/Libraries/Blob/__tests__/FileReader-test.js +++ b/packages/react-native/Libraries/Blob/__tests__/FileReader-test.js @@ -12,8 +12,12 @@ import type Event from '../../../src/private/webapis/dom/events/Event'; +import DOMException from '../../../src/private/webapis/errors/DOMException'; + +const FileReaderModuleMock = require('../__mocks__/FileReaderModule').default; const Blob = require('../Blob').default; const FileReader = require('../FileReader').default; +const NativeFileReaderModule = require('../NativeFileReaderModule').default; jest.mock('../../BatchedBridge/NativeModules', () => ({ __esModule: true, @@ -24,6 +28,10 @@ jest.mock('../../BatchedBridge/NativeModules', () => ({ })); describe('FileReader', function () { + afterEach(() => { + jest.restoreAllMocks(); + }); + it('should read blob as text', async () => { const e = await new Promise((resolve, reject) => { const reader = new FileReader(); @@ -69,6 +77,64 @@ describe('FileReader', function () { reader.abort(); expect(aborted).toBe(true); expect(loadended).toBe(true); + expect(reader.readyState).toBe(FileReader.DONE); + expect(reader.result).toBe(null); + }); + + it('should preserve a read started by an abort handler', async () => { + const reader = new FileReader(); + let loadendCount = 0; + const replacementRead = new Promise(resolve => { + reader.onloadend = () => { + loadendCount++; + resolve(); + }; + }); + reader.onabort = () => { + reader.readAsText(new Blob()); + }; + + reader.readAsText(new Blob()); + reader.abort(); + + expect(reader.readyState).toBe(FileReader.LOADING); + expect(loadendCount).toBe(0); + + await replacementRead; + expect(reader.readyState).toBe(FileReader.DONE); + expect(reader.result).toBe(''); + expect(loadendCount).toBe(1); + }); + + it('should clear stale result and error when starting a read', async () => { + const reader = new FileReader(); + const readAsText = jest.spyOn(NativeFileReaderModule, 'readAsText'); + + const successfulRead = new Promise(resolve => { + reader.onloadend = () => resolve(); + }); + reader.readAsText(new Blob()); + await successfulRead; + expect(reader.result).toBe(''); + + const error = new Error('read failed'); + readAsText.mockRejectedValueOnce(error); + const failedRead = new Promise(resolve => { + reader.onloadend = () => resolve(); + }); + reader.readAsText(new Blob()); + expect(reader.result).toBe(null); + expect(reader.error).toBe(null); + await failedRead; + expect(reader.error).toBeInstanceOf(DOMException); + expect(reader.error?.message).toBe(error.message); + + readAsText.mockReturnValueOnce(new Promise(() => {})); + reader.readAsText(new Blob()); + expect(reader.result).toBe(null); + expect(reader.error).toBe(null); + + readAsText.mockRestore(); }); it('should read blob as ArrayBuffer', async () => { @@ -84,4 +150,129 @@ describe('FileReader', function () { // $FlowFixMe[cannot-resolve-name] expect(new TextDecoder().decode(ab)).toBe('42'); }); + + it('fires lifecycle events in spec order for a successful read', async () => { + const reader = new FileReader(); + const events: Array = []; + const done = new Promise(resolve => { + for (const type of ['loadstart', 'load', 'loadend']) { + reader.addEventListener(type, () => { + events.push(type); + if (type === 'loadend') { + resolve(); + } + }); + } + reader.readAsText(new Blob()); + }); + await done; + expect(events).toEqual(['loadstart', 'load', 'loadend']); + }); + + it('fires loadstart with the reader in the LOADING state', async () => { + const reader = new FileReader(); + let stateAtLoadStart: ?number = null; + const done = new Promise(resolve => { + reader.onloadstart = () => { + stateAtLoadStart = reader.readyState; + }; + reader.onload = resolve; + reader.readAsText(new Blob()); + }); + await done; + expect(stateAtLoadStart).toBe(FileReader.LOADING); + }); + + it('does not dispatch a progress event (native reads are atomic)', async () => { + const reader = new FileReader(); + let progressed = false; + const done = new Promise((resolve, reject) => { + reader.onprogress = () => { + progressed = true; + }; + reader.onload = resolve; + reader.onerror = reject; + reader.readAsText(new Blob()); + }); + await done; + expect(progressed).toBe(false); + }); + + it('dispatches readystatechange for EMPTY -> LOADING -> DONE', async () => { + const reader = new FileReader(); + const states: Array = []; + const done = new Promise(resolve => { + reader.addEventListener('readystatechange', () => { + states.push(reader.readyState); + }); + reader.onload = resolve; + reader.readAsText(new Blob()); + }); + await done; + expect(states).toEqual([FileReader.LOADING, FileReader.DONE]); + }); + + it('fires error and loadend (not load) when the native read rejects', async () => { + jest + .spyOn(FileReaderModuleMock, 'readAsText') + .mockRejectedValueOnce(new Error('read failed')); + + const reader = new FileReader(); + let loaded = false; + let errored = false; + const done = new Promise(resolve => { + reader.onload = () => { + loaded = true; + }; + reader.onerror = () => { + errored = true; + }; + reader.onloadend = resolve; + reader.readAsText(new Blob()); + }); + await done; + expect(errored).toBe(true); + expect(loaded).toBe(false); + expect(reader.readyState).toBe(FileReader.DONE); + expect(reader.result).toBe(null); + }); + + it('exposes a read failure as a DOMException', async () => { + jest + .spyOn(FileReaderModuleMock, 'readAsText') + .mockRejectedValueOnce(new Error('read failed')); + + const reader = new FileReader(); + await new Promise(resolve => { + reader.onloadend = resolve; + reader.readAsText(new Blob()); + }); + expect(reader.error).toBeInstanceOf(DOMException); + expect(reader.error?.name).toBe('NotReadableError'); + }); + + it('throws InvalidStateError when a read starts while LOADING', () => { + const reader = new FileReader(); + reader.readAsText(new Blob()); + expect(reader.readyState).toBe(FileReader.LOADING); + + let thrown: unknown = null; + try { + reader.readAsText(new Blob()); + } catch (e) { + thrown = e; + } + expect(thrown).toBeInstanceOf(DOMException); + if (thrown instanceof DOMException) { + expect(thrown.name).toBe('InvalidStateError'); + } + // The in-flight read is untouched. + expect(reader.readyState).toBe(FileReader.LOADING); + }); + + it('throws a TypeError when the blob is null', () => { + const reader = new FileReader(); + expect(() => reader.readAsText(null)).toThrow(TypeError); + expect(reader.readyState).toBe(FileReader.EMPTY); + }); });