From 1c841dcf3b71b744f90ee599ce25fd4819477fee Mon Sep 17 00:00:00 2001 From: abbo Date: Mon, 27 Jul 2026 19:45:53 -0700 Subject: [PATCH 1/2] Generated from a GitHub Pull Request. Run 'jf sync' on this diff to load the correct commit data. Differential Revision: D113802292 --- .../react-native/Libraries/Blob/FileReader.js | 48 ++++++++------- .../Blob/__tests__/FileReader-test.js | 58 +++++++++++++++++++ 2 files changed, 84 insertions(+), 22 deletions(-) diff --git a/packages/react-native/Libraries/Blob/FileReader.js b/packages/react-native/Libraries/Blob/FileReader.js index 3745c00923a8..df2df9e2e75a 100644 --- a/packages/react-native/Libraries/Blob/FileReader.js +++ b/packages/react-native/Libraries/Blob/FileReader.js @@ -44,6 +44,7 @@ class FileReader extends EventTarget { _error: ?Error; _result: ?ReaderResult; _aborted: boolean = false; + _readId: number = 0; constructor() { super(); @@ -56,6 +57,15 @@ class FileReader extends EventTarget { this._result = null; } + _startRead(): number { + 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')); @@ -67,24 +77,24 @@ 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; - 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(); NativeFileReaderModule.readAsDataURL(blob.data).then( (text: string) => { - if (this._aborted) { + if (readId !== this._readId) { return; } @@ -95,7 +105,7 @@ class FileReader extends EventTarget { this._setReadyState(DONE); }, error => { - if (this._aborted) { + if (readId !== this._readId) { return; } this._error = error; @@ -105,26 +115,24 @@ class FileReader extends EventTarget { } 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(); 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; @@ -134,26 +142,24 @@ class FileReader extends EventTarget { } 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(); 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; @@ -163,14 +169,12 @@ class FileReader extends EventTarget { } 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 { diff --git a/packages/react-native/Libraries/Blob/__tests__/FileReader-test.js b/packages/react-native/Libraries/Blob/__tests__/FileReader-test.js index 36d55849c487..da61fa1a6d55 100644 --- a/packages/react-native/Libraries/Blob/__tests__/FileReader-test.js +++ b/packages/react-native/Libraries/Blob/__tests__/FileReader-test.js @@ -14,6 +14,7 @@ import type Event from '../../../src/private/webapis/dom/events/Event'; const Blob = require('../Blob').default; const FileReader = require('../FileReader').default; +const NativeFileReaderModule = require('../NativeFileReaderModule').default; jest.mock('../../BatchedBridge/NativeModules', () => ({ __esModule: true, @@ -69,6 +70,63 @@ 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).toBe(error); + + 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 () => { From 31fce511c9cd88e8058430276577aa87d4469987 Mon Sep 17 00:00:00 2001 From: Peter Abbondanzo Date: Tue, 28 Jul 2026 11:40:56 -0700 Subject: [PATCH 2/2] Close remaining File API spec gaps in FileReader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Builds on [#57692](https://github.com/facebook/react-native/pull/57692), which fixed the `abort()` state machine and reset `result`/`error` at the start of each read via the `_startRead()` helper. A few gaps remain versus the [File API spec](https://w3c.github.io/FileAPI/): a read that starts while the reader is `LOADING` is silently superseded instead of throwing, `loadstart` is never dispatched, and `error` is a plain `Error` rather than a `DOMException`. This change: - throws an `InvalidStateError` `DOMException` when a read starts while the reader is `LOADING`, by extending the `_startRead()` helper - fires `loadstart` when a read begins - exposes `error` as a `DOMException` (`NotReadableError`), matching the spec typing No `progress` event is synthesized: `NativeFileReaderModule` resolves the whole payload in a single promise, so there is no incremental read to observe. The spec fires `progress` opportunistically as bytes stream in (it is not a required event), and emitting one post-completion `progress` with `loaded === total` would report data the reader never actually measured — a handler would also see `reader.result === null` at that point. The lifecycle is covered by `loadstart`/`load`/`error`/`abort`/`loadend`. The legacy `readystatechange` event is left untouched, and the deprecated `readAsBinaryString()` is out of scope. ## Changelog: [GENERAL] [FIXED] - Fire `loadstart`, throw `InvalidStateError` on overlapping reads, and expose `FileReader.error` as a `DOMException`. Differential Revision: D113819663 --- .../react-native/Libraries/Blob/FileReader.js | 39 +++-- .../Blob/__tests__/FileReader-test.js | 135 +++++++++++++++++- 2 files changed, 163 insertions(+), 11 deletions(-) diff --git a/packages/react-native/Libraries/Blob/FileReader.js b/packages/react-native/Libraries/Blob/FileReader.js index df2df9e2e75a..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,7 +42,7 @@ class FileReader extends EventTarget { DONE: number = DONE; _readyState: ReadyState; - _error: ?Error; + _error: ?DOMException; _result: ?ReaderResult; _aborted: boolean = false; _readId: number = 0; @@ -57,7 +58,13 @@ class FileReader extends EventTarget { this._result = null; } - _startRead(): number { + _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; @@ -69,7 +76,9 @@ class FileReader extends EventTarget { _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) { @@ -83,6 +92,16 @@ class FileReader extends EventTarget { } } + _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( @@ -90,7 +109,7 @@ class FileReader extends EventTarget { ); } - const readId = this._startRead(); + const readId = this._startRead('readAsArrayBuffer'); NativeFileReaderModule.readAsDataURL(blob.data).then( (text: string) => { @@ -108,7 +127,7 @@ class FileReader extends EventTarget { if (readId !== this._readId) { return; } - this._error = error; + this._error = this._toDOMException(error); this._setReadyState(DONE); }, ); @@ -121,7 +140,7 @@ class FileReader extends EventTarget { ); } - const readId = this._startRead(); + const readId = this._startRead('readAsDataURL'); NativeFileReaderModule.readAsDataURL(blob.data).then( (text: string) => { @@ -135,7 +154,7 @@ class FileReader extends EventTarget { if (readId !== this._readId) { return; } - this._error = error; + this._error = this._toDOMException(error); this._setReadyState(DONE); }, ); @@ -148,7 +167,7 @@ class FileReader extends EventTarget { ); } - const readId = this._startRead(); + const readId = this._startRead('readAsText'); NativeFileReaderModule.readAsText(blob.data, encoding).then( (text: string) => { @@ -162,7 +181,7 @@ class FileReader extends EventTarget { if (readId !== this._readId) { return; } - this._error = error; + this._error = this._toDOMException(error); this._setReadyState(DONE); }, ); @@ -181,7 +200,7 @@ class FileReader extends EventTarget { 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 da61fa1a6d55..69d02080ab94 100644 --- a/packages/react-native/Libraries/Blob/__tests__/FileReader-test.js +++ b/packages/react-native/Libraries/Blob/__tests__/FileReader-test.js @@ -12,6 +12,9 @@ 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; @@ -25,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(); @@ -119,7 +126,8 @@ describe('FileReader', function () { expect(reader.result).toBe(null); expect(reader.error).toBe(null); await failedRead; - expect(reader.error).toBe(error); + expect(reader.error).toBeInstanceOf(DOMException); + expect(reader.error?.message).toBe(error.message); readAsText.mockReturnValueOnce(new Promise(() => {})); reader.readAsText(new Blob()); @@ -142,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); + }); });