diff --git a/packages/react-native/Libraries/Blob/FileReader.js b/packages/react-native/Libraries/Blob/FileReader.js index 98e7f8c9da1..3f8f634104c 100644 --- a/packages/react-native/Libraries/Blob/FileReader.js +++ b/packages/react-native/Libraries/Blob/FileReader.js @@ -46,6 +46,13 @@ class FileReader extends EventTarget { _result: ?ReaderResult; _aborted: boolean = false; _readId: number = 0; + // The blob being read must stay strongly referenced until the native read + // settles. Otherwise, if the caller drops its own reference (as + // whatwg-fetch does in `readBlobAsText`), the Blob can be garbage + // collected while the read is still in flight, and its BlobCollector + // finalizer deallocates the underlying native buffer, failing the read + // with "The specified blob is invalid". + _blob: ?Blob; constructor() { super(); @@ -56,6 +63,7 @@ class FileReader extends EventTarget { this._readyState = EMPTY; this._error = null; this._result = null; + this._blob = null; } _startRead(methodName: string): number { @@ -110,12 +118,14 @@ class FileReader extends EventTarget { } const readId = this._startRead('readAsArrayBuffer'); + this._blob = blob; NativeFileReaderModule.readAsDataURL(blob.data).then( (text: string) => { if (readId !== this._readId) { return; } + this._blob = null; const base64 = text.split(',')[1]; const typedArray = toByteArray(base64); @@ -127,6 +137,7 @@ class FileReader extends EventTarget { if (readId !== this._readId) { return; } + this._blob = null; this._error = this._toDOMException(error); this._setReadyState(DONE); }, @@ -141,12 +152,14 @@ class FileReader extends EventTarget { } const readId = this._startRead('readAsDataURL'); + this._blob = blob; NativeFileReaderModule.readAsDataURL(blob.data).then( (text: string) => { if (readId !== this._readId) { return; } + this._blob = null; this._result = text; this._setReadyState(DONE); }, @@ -154,6 +167,7 @@ class FileReader extends EventTarget { if (readId !== this._readId) { return; } + this._blob = null; this._error = this._toDOMException(error); this._setReadyState(DONE); }, @@ -168,12 +182,14 @@ class FileReader extends EventTarget { } const readId = this._startRead('readAsText'); + this._blob = blob; NativeFileReaderModule.readAsText(blob.data, encoding).then( (text: string) => { if (readId !== this._readId) { return; } + this._blob = null; this._result = text; this._setReadyState(DONE); }, @@ -181,6 +197,7 @@ class FileReader extends EventTarget { if (readId !== this._readId) { return; } + this._blob = null; this._error = this._toDOMException(error); this._setReadyState(DONE); }, @@ -192,6 +209,10 @@ class FileReader extends EventTarget { if (this._readyState === LOADING) { this._aborted = true; this._readId++; + // The abandoned read's callbacks bail out on the readId check without + // clearing _blob, so release it here — before dispatching the abort + // event, whose handler may start a new read that sets _blob again. + this._blob = null; this._setReadyState(DONE); } } diff --git a/packages/react-native/Libraries/Blob/__tests__/FileReader-test.js b/packages/react-native/Libraries/Blob/__tests__/FileReader-test.js index 69d02080ab9..0f82773b844 100644 --- a/packages/react-native/Libraries/Blob/__tests__/FileReader-test.js +++ b/packages/react-native/Libraries/Blob/__tests__/FileReader-test.js @@ -275,4 +275,108 @@ describe('FileReader', function () { expect(() => reader.readAsText(null)).toThrow(TypeError); expect(reader.readyState).toBe(FileReader.EMPTY); }); + + it('should retain the blob until the read resolves', async () => { + let resolveRead: string => void = () => {}; + const spy = jest + .spyOn(FileReaderModuleMock, 'readAsText') + .mockImplementation( + () => + new Promise(resolve => { + resolveRead = resolve; + }), + ); + + const reader = new FileReader(); + const blob = new Blob(); + const loadend = new Promise(resolve => { + reader.onloadend = resolve; + }); + reader.readAsText(blob); + // $FlowFixMe[prop-missing] - accessing private state for the test + expect(reader._blob).toBe(blob); + + resolveRead(''); + await loadend; + // $FlowFixMe[prop-missing] - accessing private state for the test + expect(reader._blob).toBe(null); + + spy.mockRestore(); + }); + + it('should release the blob when the read rejects', async () => { + let rejectRead: Error => void = () => {}; + const spy = jest + .spyOn(FileReaderModuleMock, 'readAsText') + .mockImplementation( + () => + new Promise((resolve, reject) => { + rejectRead = reject; + }), + ); + + const reader = new FileReader(); + const blob = new Blob(); + const loadend = new Promise(resolve => { + reader.onloadend = resolve; + }); + reader.readAsText(blob); + // $FlowFixMe[prop-missing] - accessing private state for the test + expect(reader._blob).toBe(blob); + + rejectRead(new Error('nope')); + await loadend; + // $FlowFixMe[prop-missing] - accessing private state for the test + expect(reader._blob).toBe(null); + + spy.mockRestore(); + }); + + it('should release the blob when a pending read is aborted', () => { + const spy = jest + .spyOn(FileReaderModuleMock, 'readAsText') + .mockImplementation(() => new Promise(() => {})); + + const reader = new FileReader(); + const blob = new Blob(); + reader.readAsText(blob); + // $FlowFixMe[prop-missing] - accessing private state for the test + expect(reader._blob).toBe(blob); + + reader.abort(); + // $FlowFixMe[prop-missing] - accessing private state for the test + expect(reader._blob).toBe(null); + + spy.mockRestore(); + }); + + it('should keep retaining the new blob when a stale read settles after abort', async () => { + const resolvers: Array<(string) => void> = []; + const spy = jest + .spyOn(FileReaderModuleMock, 'readAsText') + .mockImplementation( + () => + new Promise(resolve => { + resolvers.push(resolve); + }), + ); + + const reader = new FileReader(); + const staleBlob = new Blob(); + reader.readAsText(staleBlob); + reader.abort(); + + const newBlob = new Blob(); + reader.readAsText(newBlob); + // $FlowFixMe[prop-missing] - accessing private state for the test + expect(reader._blob).toBe(newBlob); + + // Settle the first (aborted) read; it must not drop the new blob. + resolvers[0](''); + await Promise.resolve(); + // $FlowFixMe[prop-missing] - accessing private state for the test + expect(reader._blob).toBe(newBlob); + + spy.mockRestore(); + }); });