From 7dc34c5cde67efc5ff0f5c42c4f38d04b3d891e3 Mon Sep 17 00:00:00 2001 From: heecheolman Date: Fri, 31 Jul 2026 18:28:16 +0900 Subject: [PATCH] Fix premature blob deallocation during FileReader reads FileReader dispatched the native read with only the plain blob.data descriptor and kept no reference to the Blob instance. If the caller also drops its reference (which is exactly what whatwg-fetch's readBlobAsText does, i.e. every fetch().json() in React Native), the Blob and its attached BlobCollector become unreachable while the native read is still queued. When GC runs in that window, the collector's finalizer removes the bytes from BlobModule's store and the read rejects with "The specified blob is invalid". Retain the Blob on the FileReader instance until the read settles, so the reference chain pending native promise -> callbacks -> reader -> _blob -> Blob -> collector keeps the native buffer alive for the duration of the read. Co-Authored-By: Claude Fable 5 --- .../react-native/Libraries/Blob/FileReader.js | 21 ++++ .../Blob/__tests__/FileReader-test.js | 104 ++++++++++++++++++ 2 files changed, 125 insertions(+) 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(); + }); });