Skip to content

Commit 544141e

Browse files
Abbondanzometa-codesync[bot]
authored andcommitted
Close remaining File API spec gaps in FileReader (#57745)
Summary: Pull Request resolved: #57745 Builds on [#57692](#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`. Reviewed By: christophpurrer Differential Revision: D113819663 fbshipit-source-id: c7c37b3b578d825cdccf73840b2c0742b6a4eaf9
1 parent ca750fe commit 544141e

2 files changed

Lines changed: 163 additions & 11 deletions

File tree

packages/react-native/Libraries/Blob/FileReader.js

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
setEventHandlerAttribute,
1818
} from '../../src/private/webapis/dom/events/EventHandlerAttributes';
1919
import EventTarget from '../../src/private/webapis/dom/events/EventTarget';
20+
import DOMException from '../../src/private/webapis/errors/DOMException';
2021
import NativeFileReaderModule from './NativeFileReaderModule';
2122
import {toByteArray} from 'base64-js';
2223

@@ -41,7 +42,7 @@ class FileReader extends EventTarget {
4142
DONE: number = DONE;
4243

4344
_readyState: ReadyState;
44-
_error: ?Error;
45+
_error: ?DOMException;
4546
_result: ?ReaderResult;
4647
_aborted: boolean = false;
4748
_readId: number = 0;
@@ -57,7 +58,13 @@ class FileReader extends EventTarget {
5758
this._result = null;
5859
}
5960

60-
_startRead(): number {
61+
_startRead(methodName: string): number {
62+
if (this._readyState === LOADING) {
63+
throw new DOMException(
64+
`Failed to execute '${methodName}' on 'FileReader': The object is already busy reading Blobs.`,
65+
'InvalidStateError',
66+
);
67+
}
6168
this._aborted = false;
6269
this._error = null;
6370
this._result = null;
@@ -69,7 +76,9 @@ class FileReader extends EventTarget {
6976
_setReadyState(newState: ReadyState) {
7077
this._readyState = newState;
7178
this.dispatchEvent(new Event('readystatechange'));
72-
if (newState === DONE) {
79+
if (newState === LOADING) {
80+
this.dispatchEvent(new Event('loadstart'));
81+
} else if (newState === DONE) {
7382
if (this._aborted) {
7483
this.dispatchEvent(new Event('abort'));
7584
} else if (this._error) {
@@ -83,14 +92,24 @@ class FileReader extends EventTarget {
8392
}
8493
}
8594

95+
_toDOMException(error: unknown): DOMException {
96+
if (error instanceof DOMException) {
97+
return error;
98+
}
99+
if (error instanceof Error) {
100+
return new DOMException(error.message, 'NotReadableError');
101+
}
102+
return new DOMException(String(error), 'NotReadableError');
103+
}
104+
86105
readAsArrayBuffer(blob: ?Blob): void {
87106
if (blob == null) {
88107
throw new TypeError(
89108
"Failed to execute 'readAsArrayBuffer' on 'FileReader': parameter 1 is not of type 'Blob'",
90109
);
91110
}
92111

93-
const readId = this._startRead();
112+
const readId = this._startRead('readAsArrayBuffer');
94113

95114
NativeFileReaderModule.readAsDataURL(blob.data).then(
96115
(text: string) => {
@@ -108,7 +127,7 @@ class FileReader extends EventTarget {
108127
if (readId !== this._readId) {
109128
return;
110129
}
111-
this._error = error;
130+
this._error = this._toDOMException(error);
112131
this._setReadyState(DONE);
113132
},
114133
);
@@ -121,7 +140,7 @@ class FileReader extends EventTarget {
121140
);
122141
}
123142

124-
const readId = this._startRead();
143+
const readId = this._startRead('readAsDataURL');
125144

126145
NativeFileReaderModule.readAsDataURL(blob.data).then(
127146
(text: string) => {
@@ -135,7 +154,7 @@ class FileReader extends EventTarget {
135154
if (readId !== this._readId) {
136155
return;
137156
}
138-
this._error = error;
157+
this._error = this._toDOMException(error);
139158
this._setReadyState(DONE);
140159
},
141160
);
@@ -148,7 +167,7 @@ class FileReader extends EventTarget {
148167
);
149168
}
150169

151-
const readId = this._startRead();
170+
const readId = this._startRead('readAsText');
152171

153172
NativeFileReaderModule.readAsText(blob.data, encoding).then(
154173
(text: string) => {
@@ -162,7 +181,7 @@ class FileReader extends EventTarget {
162181
if (readId !== this._readId) {
163182
return;
164183
}
165-
this._error = error;
184+
this._error = this._toDOMException(error);
166185
this._setReadyState(DONE);
167186
},
168187
);
@@ -181,7 +200,7 @@ class FileReader extends EventTarget {
181200
return this._readyState;
182201
}
183202

184-
get error(): ?Error {
203+
get error(): ?DOMException {
185204
return this._error;
186205
}
187206

packages/react-native/Libraries/Blob/__tests__/FileReader-test.js

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212

1313
import type Event from '../../../src/private/webapis/dom/events/Event';
1414

15+
import DOMException from '../../../src/private/webapis/errors/DOMException';
16+
17+
const FileReaderModuleMock = require('../__mocks__/FileReaderModule').default;
1518
const Blob = require('../Blob').default;
1619
const FileReader = require('../FileReader').default;
1720
const NativeFileReaderModule = require('../NativeFileReaderModule').default;
@@ -25,6 +28,10 @@ jest.mock('../../BatchedBridge/NativeModules', () => ({
2528
}));
2629

2730
describe('FileReader', function () {
31+
afterEach(() => {
32+
jest.restoreAllMocks();
33+
});
34+
2835
it('should read blob as text', async () => {
2936
const e = await new Promise<Event>((resolve, reject) => {
3037
const reader = new FileReader();
@@ -119,7 +126,8 @@ describe('FileReader', function () {
119126
expect(reader.result).toBe(null);
120127
expect(reader.error).toBe(null);
121128
await failedRead;
122-
expect(reader.error).toBe(error);
129+
expect(reader.error).toBeInstanceOf(DOMException);
130+
expect(reader.error?.message).toBe(error.message);
123131

124132
readAsText.mockReturnValueOnce(new Promise(() => {}));
125133
reader.readAsText(new Blob());
@@ -142,4 +150,129 @@ describe('FileReader', function () {
142150
// $FlowFixMe[cannot-resolve-name]
143151
expect(new TextDecoder().decode(ab)).toBe('42');
144152
});
153+
154+
it('fires lifecycle events in spec order for a successful read', async () => {
155+
const reader = new FileReader();
156+
const events: Array<string> = [];
157+
const done = new Promise<void>(resolve => {
158+
for (const type of ['loadstart', 'load', 'loadend']) {
159+
reader.addEventListener(type, () => {
160+
events.push(type);
161+
if (type === 'loadend') {
162+
resolve();
163+
}
164+
});
165+
}
166+
reader.readAsText(new Blob());
167+
});
168+
await done;
169+
expect(events).toEqual(['loadstart', 'load', 'loadend']);
170+
});
171+
172+
it('fires loadstart with the reader in the LOADING state', async () => {
173+
const reader = new FileReader();
174+
let stateAtLoadStart: ?number = null;
175+
const done = new Promise<Event>(resolve => {
176+
reader.onloadstart = () => {
177+
stateAtLoadStart = reader.readyState;
178+
};
179+
reader.onload = resolve;
180+
reader.readAsText(new Blob());
181+
});
182+
await done;
183+
expect(stateAtLoadStart).toBe(FileReader.LOADING);
184+
});
185+
186+
it('does not dispatch a progress event (native reads are atomic)', async () => {
187+
const reader = new FileReader();
188+
let progressed = false;
189+
const done = new Promise<Event>((resolve, reject) => {
190+
reader.onprogress = () => {
191+
progressed = true;
192+
};
193+
reader.onload = resolve;
194+
reader.onerror = reject;
195+
reader.readAsText(new Blob());
196+
});
197+
await done;
198+
expect(progressed).toBe(false);
199+
});
200+
201+
it('dispatches readystatechange for EMPTY -> LOADING -> DONE', async () => {
202+
const reader = new FileReader();
203+
const states: Array<number> = [];
204+
const done = new Promise<Event>(resolve => {
205+
reader.addEventListener('readystatechange', () => {
206+
states.push(reader.readyState);
207+
});
208+
reader.onload = resolve;
209+
reader.readAsText(new Blob());
210+
});
211+
await done;
212+
expect(states).toEqual([FileReader.LOADING, FileReader.DONE]);
213+
});
214+
215+
it('fires error and loadend (not load) when the native read rejects', async () => {
216+
jest
217+
.spyOn(FileReaderModuleMock, 'readAsText')
218+
.mockRejectedValueOnce(new Error('read failed'));
219+
220+
const reader = new FileReader();
221+
let loaded = false;
222+
let errored = false;
223+
const done = new Promise<Event>(resolve => {
224+
reader.onload = () => {
225+
loaded = true;
226+
};
227+
reader.onerror = () => {
228+
errored = true;
229+
};
230+
reader.onloadend = resolve;
231+
reader.readAsText(new Blob());
232+
});
233+
await done;
234+
expect(errored).toBe(true);
235+
expect(loaded).toBe(false);
236+
expect(reader.readyState).toBe(FileReader.DONE);
237+
expect(reader.result).toBe(null);
238+
});
239+
240+
it('exposes a read failure as a DOMException', async () => {
241+
jest
242+
.spyOn(FileReaderModuleMock, 'readAsText')
243+
.mockRejectedValueOnce(new Error('read failed'));
244+
245+
const reader = new FileReader();
246+
await new Promise<Event>(resolve => {
247+
reader.onloadend = resolve;
248+
reader.readAsText(new Blob());
249+
});
250+
expect(reader.error).toBeInstanceOf(DOMException);
251+
expect(reader.error?.name).toBe('NotReadableError');
252+
});
253+
254+
it('throws InvalidStateError when a read starts while LOADING', () => {
255+
const reader = new FileReader();
256+
reader.readAsText(new Blob());
257+
expect(reader.readyState).toBe(FileReader.LOADING);
258+
259+
let thrown: unknown = null;
260+
try {
261+
reader.readAsText(new Blob());
262+
} catch (e) {
263+
thrown = e;
264+
}
265+
expect(thrown).toBeInstanceOf(DOMException);
266+
if (thrown instanceof DOMException) {
267+
expect(thrown.name).toBe('InvalidStateError');
268+
}
269+
// The in-flight read is untouched.
270+
expect(reader.readyState).toBe(FileReader.LOADING);
271+
});
272+
273+
it('throws a TypeError when the blob is null', () => {
274+
const reader = new FileReader();
275+
expect(() => reader.readAsText(null)).toThrow(TypeError);
276+
expect(reader.readyState).toBe(FileReader.EMPTY);
277+
});
145278
});

0 commit comments

Comments
 (0)