Skip to content

Commit 34c8836

Browse files
committed
feat: read blob bytes directly in FileReader.readAsArrayBuffer
1 parent fabef5a commit 34c8836

23 files changed

Lines changed: 1160 additions & 41 deletions

File tree

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

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import {
1919
import EventTarget from '../../src/private/webapis/dom/events/EventTarget';
2020
import DOMException from '../../src/private/webapis/errors/DOMException';
2121
import NativeFileReaderModule from './NativeFileReaderModule';
22-
import {toByteArray} from 'base64-js';
2322

2423
type ReadyState =
2524
| 0 // EMPTY
@@ -123,17 +122,14 @@ class FileReader extends EventTarget {
123122
this._blob = blob;
124123
}
125124

126-
NativeFileReaderModule.readAsDataURL(blob.data).then(
127-
(text: string) => {
125+
NativeFileReaderModule.readAsArrayBuffer(blob.data).then(
126+
(buffer: ArrayBuffer) => {
128127
if (readId !== this._readId) {
129128
return;
130129
}
131130
this._blob = null;
132131

133-
const base64 = text.split(',')[1];
134-
const typedArray = toByteArray(base64);
135-
136-
this._result = typedArray.buffer;
132+
this._result = buffer;
137133
this._setReadyState(DONE);
138134
},
139135
error => {

packages/react-native/Libraries/Blob/RCTBlobManager.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323

2424
- (NSData *)resolve:(NSString *)blobId offset:(NSInteger)offset size:(NSInteger)size;
2525

26+
- (RCTArrayBuffer *)resolveBuffer:(NSString *)blobId offset:(NSInteger)offset size:(NSInteger)size;
27+
2628
- (NSData *)resolveURL:(NSURL *)url;
2729

2830
- (void)remove:(NSString *)blobId;

packages/react-native/Libraries/Blob/RCTBlobManager.mm

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,26 @@ - (NSData *)resolve:(NSString *)blobId offset:(NSInteger)offset size:(NSInteger)
117117
return data;
118118
}
119119

120+
- (RCTArrayBuffer *)resolveBuffer:(NSString *)blobId offset:(NSInteger)offset size:(NSInteger)size
121+
{
122+
NSData *stored;
123+
{
124+
std::lock_guard<std::mutex> lock(_blobsMutex);
125+
stored = _blobs[blobId];
126+
}
127+
if (!stored) {
128+
return nil;
129+
}
130+
NSInteger length = size == -1 ? (NSInteger)stored.length - offset : size;
131+
if (offset < 0 || length < 0 || offset + length > (NSInteger)stored.length) {
132+
return nil;
133+
}
134+
// One copy: the requested range goes straight into the new buffer. The copy is
135+
// required because this RCTArrayBuffer is handed to JS zero-copy, and Blobs are
136+
// immutable per the W3C File API — JS must not write through to blob storage.
137+
return [RCTArrayBuffer arrayBufferWithCopiedBytes:(const uint8_t *)stored.bytes + offset length:(NSUInteger)length];
138+
}
139+
120140
- (NSData *)resolveURL:(NSURL *)url
121141
{
122142
NSURLComponents *components = [[NSURLComponents alloc] initWithURL:url resolvingAgainstBaseURL:NO];

packages/react-native/Libraries/Blob/RCTFileReaderModule.mm

Lines changed: 42 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,15 @@
99

1010
#import <FBReactNativeSpec/FBReactNativeSpec.h>
1111
#import <React/RCTBridge.h>
12-
#import <React/RCTConvert.h>
12+
#import <React/RCTUtils.h>
1313

14+
#import <React/RCTArrayBuffer.h>
1415
#import <React/RCTBlobManager.h>
1516

1617
#import "RCTBlobPlugins.h"
1718

19+
static NSString *const kRCTFileReaderInvalidBlobError = @"ERROR_INVALID_BLOB";
20+
1821
@interface RCTFileReaderModule () <NativeFileReaderModuleSpec>
1922
@end
2023

@@ -24,20 +27,21 @@ @implementation RCTFileReaderModule
2427

2528
@synthesize moduleRegistry = _moduleRegistry;
2629

27-
- (void)readAsText:(NSDictionary<NSString *, id> *)blob
30+
- (void)readAsText:(JS::NativeFileReaderModule::BlobDescriptor &)blob
2831
encoding:(NSString *)encoding
2932
resolve:(RCTPromiseResolveBlock)resolve
3033
reject:(RCTPromiseRejectBlock)reject
3134
{
35+
NSString *blobId = blob.blobId();
36+
NSInteger offset = (NSInteger)blob.offset();
37+
NSInteger size = (NSInteger)blob.size();
38+
3239
RCTBlobManager *blobManager = [_moduleRegistry moduleForName:"BlobModule"];
3340
dispatch_async(blobManager.methodQueue, ^{
34-
NSData *data = [blobManager resolve:blob];
41+
NSData *data = [blobManager resolve:blobId offset:offset size:size];
3542

3643
if (data == nil) {
37-
reject(
38-
RCTErrorUnspecified,
39-
[NSString stringWithFormat:@"Unable to resolve data for blob: %@", [RCTConvert NSString:blob[@"blobId"]]],
40-
nil);
44+
reject(RCTErrorUnspecified, [NSString stringWithFormat:@"Unable to resolve data for blob: %@", blobId], nil);
4145
} else {
4246
NSStringEncoding stringEncoding;
4347

@@ -55,31 +59,51 @@ - (void)readAsText:(NSDictionary<NSString *, id> *)blob
5559
});
5660
}
5761

58-
- (void)readAsDataURL:(NSDictionary<NSString *, id> *)blob
62+
- (void)readAsDataURL:(JS::NativeFileReaderModule::BlobDescriptor &)blob
5963
resolve:(RCTPromiseResolveBlock)resolve
6064
reject:(RCTPromiseRejectBlock)reject
6165
{
66+
NSString *blobId = blob.blobId();
67+
NSInteger offset = (NSInteger)blob.offset();
68+
NSInteger size = (NSInteger)blob.size();
69+
NSString *type = blob.type();
70+
6271
RCTBlobManager *blobManager = [_moduleRegistry moduleForName:"BlobModule"];
6372
dispatch_async(blobManager.methodQueue, ^{
64-
NSData *data = [blobManager resolve:blob];
73+
NSData *data = [blobManager resolve:blobId offset:offset size:size];
6574

6675
if (data == nil) {
67-
reject(
68-
RCTErrorUnspecified,
69-
[NSString stringWithFormat:@"Unable to resolve data for blob: %@", [RCTConvert NSString:blob[@"blobId"]]],
70-
nil);
76+
reject(RCTErrorUnspecified, [NSString stringWithFormat:@"Unable to resolve data for blob: %@", blobId], nil);
7177
} else {
72-
NSString *type = [RCTConvert NSString:blob[@"type"]];
73-
NSString *text = [NSString
74-
stringWithFormat:@"data:%@;base64,%@",
75-
![type isEqual:[NSNull null]] && [type length] > 0 ? type : @"application/octet-stream",
76-
[data base64EncodedStringWithOptions:0]];
78+
NSString *text = [NSString stringWithFormat:@"data:%@;base64,%@",
79+
type != nil && [type length] > 0 ? type : @"application/octet-stream",
80+
[data base64EncodedStringWithOptions:0]];
7781

7882
resolve(text);
7983
}
8084
});
8185
}
8286

87+
- (void)readAsArrayBuffer:(JS::NativeFileReaderModule::BlobDescriptor &)blob
88+
resolve:(RCTPromiseResolveBlock)resolve
89+
reject:(RCTPromiseRejectBlock)reject
90+
{
91+
NSString *blobId = blob.blobId();
92+
NSInteger offset = (NSInteger)blob.offset();
93+
NSInteger size = (NSInteger)blob.size();
94+
95+
RCTBlobManager *blobManager = [_moduleRegistry moduleForName:"BlobModule"];
96+
dispatch_async(blobManager.methodQueue, ^{
97+
RCTArrayBuffer *buffer = [blobManager resolveBuffer:blobId offset:offset size:size];
98+
99+
if (buffer == nil) {
100+
reject(kRCTFileReaderInvalidBlobError, @"The specified blob is invalid", nil);
101+
} else {
102+
resolve(buffer);
103+
}
104+
});
105+
}
106+
83107
- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
84108
(const facebook::react::ObjCTurboModule::InitParams &)params
85109
{

packages/react-native/Libraries/Blob/__mocks__/FileReaderModule.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
*/
1010

1111
const FileReaderModule = {
12+
async readAsArrayBuffer(): Promise<ArrayBuffer> {
13+
return Uint8Array.from([52, 50]).buffer;
14+
},
1215
async readAsText(): Promise<string> {
1316
return '';
1417
},

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

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,117 @@ describe('FileReader', function () {
151151
expect(new TextDecoder().decode(ab)).toBe('42');
152152
});
153153

154+
// The FileReaderModule mock always resolves with these bytes, regardless of
155+
// the blob it is handed.
156+
const MOCK_READ_RESULT = [52, 50];
157+
158+
it('resolves result with the ArrayBuffer the native module returns', async () => {
159+
const reader = new FileReader();
160+
161+
await new Promise<Event>(resolve => {
162+
reader.onloadend = resolve;
163+
reader.readAsArrayBuffer(new Blob());
164+
});
165+
166+
expect(reader.readyState).toBe(FileReader.DONE);
167+
expect(reader.result).toBeInstanceOf(ArrayBuffer);
168+
const result = reader.result;
169+
if (!(result instanceof ArrayBuffer)) {
170+
throw new Error('expected ArrayBuffer');
171+
}
172+
expect(Array.from(new Uint8Array(result))).toEqual(MOCK_READ_RESULT);
173+
});
174+
175+
it('throws a TypeError when readAsArrayBuffer is given null', () => {
176+
const reader = new FileReader();
177+
expect(() => reader.readAsArrayBuffer(null)).toThrow(TypeError);
178+
expect(reader.readyState).toBe(FileReader.EMPTY);
179+
});
180+
181+
it('should abort a pending ArrayBuffer read without firing load', async () => {
182+
const reader = new FileReader();
183+
let loaded = false;
184+
reader.onload = () => {
185+
loaded = true;
186+
};
187+
reader.readAsArrayBuffer(new Blob());
188+
reader.abort();
189+
190+
expect(reader.readyState).toBe(FileReader.DONE);
191+
expect(reader.result).toBe(null);
192+
193+
await Promise.resolve();
194+
expect(loaded).toBe(false);
195+
expect(reader.result).toBe(null);
196+
});
197+
198+
it('should discard a stale ArrayBuffer result when a newer read is current', async () => {
199+
const resolvers: Array<(ArrayBuffer) => void> = [];
200+
const spy = jest
201+
.spyOn(FileReaderModuleMock, 'readAsArrayBuffer')
202+
.mockImplementation(
203+
() =>
204+
new Promise(resolve => {
205+
resolvers.push(resolve);
206+
}),
207+
);
208+
209+
const reader = new FileReader();
210+
let loaded = false;
211+
reader.onload = () => {
212+
loaded = true;
213+
};
214+
215+
reader.readAsArrayBuffer(new Blob());
216+
reader.abort();
217+
expect(reader.result).toBe(null);
218+
219+
const done = new Promise<Event>(resolve => {
220+
reader.onloadend = resolve;
221+
});
222+
reader.readAsArrayBuffer(new Blob());
223+
224+
// Stale first read settles with different bytes; the readId guard must drop it.
225+
resolvers[0](Uint8Array.from([9, 9, 9]).buffer);
226+
await Promise.resolve();
227+
expect(reader.readyState).toBe(FileReader.LOADING);
228+
expect(reader.result).toBe(null);
229+
expect(loaded).toBe(false);
230+
231+
resolvers[1](Uint8Array.from([52, 50]).buffer);
232+
await done;
233+
expect(reader.readyState).toBe(FileReader.DONE);
234+
expect(reader.result).toBeInstanceOf(ArrayBuffer);
235+
const result = reader.result;
236+
if (!(result instanceof ArrayBuffer)) {
237+
throw new Error('expected ArrayBuffer');
238+
}
239+
expect(Array.from(new Uint8Array(result))).toEqual([52, 50]);
240+
expect(loaded).toBe(true);
241+
242+
spy.mockRestore();
243+
});
244+
245+
it('rejects a failed ArrayBuffer read with a NotReadableError DOMException', async () => {
246+
const spy = jest
247+
.spyOn(FileReaderModuleMock, 'readAsArrayBuffer')
248+
.mockRejectedValue(new Error('The specified blob is invalid'));
249+
250+
const reader = new FileReader();
251+
await new Promise<Event>(resolve => {
252+
reader.onloadend = resolve;
253+
reader.readAsArrayBuffer(new Blob());
254+
});
255+
256+
expect(reader.readyState).toBe(FileReader.DONE);
257+
expect(reader.result).toBe(null);
258+
expect(reader.error).not.toBe(null);
259+
expect(reader.error?.message).toBe('The specified blob is invalid');
260+
expect(reader.error?.name).toBe('NotReadableError');
261+
262+
spy.mockRestore();
263+
});
264+
154265
it('fires lifecycle events in spec order for a successful read', async () => {
155266
const reader = new FileReader();
156267
const events: Array<string> = [];

packages/react-native/ReactAndroid/api/ReactAndroid.api

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2510,6 +2510,7 @@ public final class com/facebook/react/modules/blob/BlobModule : com/facebook/fbr
25102510
public final fun resolve (Landroid/net/Uri;)[B
25112511
public final fun resolve (Lcom/facebook/react/bridge/ReadableMap;)[B
25122512
public final fun resolve (Ljava/lang/String;II)[B
2513+
public final fun resolveBuffer (Ljava/lang/String;II)Lcom/facebook/react/bridge/ArrayBuffer;
25132514
public fun sendOverSocket (Lcom/facebook/react/bridge/ReadableMap;D)V
25142515
public final fun store ([B)Ljava/lang/String;
25152516
public final fun store ([BLjava/lang/String;)V
@@ -2532,6 +2533,7 @@ public final class com/facebook/react/modules/blob/BlobProvider : android/conten
25322533
public final class com/facebook/react/modules/blob/FileReaderModule : com/facebook/fbreact/specs/NativeFileReaderModuleSpec {
25332534
public static final field Companion Lcom/facebook/react/modules/blob/FileReaderModule$Companion;
25342535
public fun <init> (Lcom/facebook/react/bridge/ReactApplicationContext;)V
2536+
public fun readAsArrayBuffer (Lcom/facebook/react/bridge/ReadableMap;Lcom/facebook/react/bridge/Promise;)V
25352537
public fun readAsDataURL (Lcom/facebook/react/bridge/ReadableMap;Lcom/facebook/react/bridge/Promise;)V
25362538
public fun readAsText (Lcom/facebook/react/bridge/ReadableMap;Ljava/lang/String;Lcom/facebook/react/bridge/Promise;)V
25372539
}

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/CxxArrayBufferCallbackImpl.kt

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,16 +36,26 @@ internal class CxxArrayBufferCallbackImpl @DoNotStrip private constructor() :
3636
}
3737
when (val arg = args.firstOrNull()) {
3838
null -> nativeInvoke(null, null)
39-
is ArrayBuffer ->
40-
if (arg.isOwningBytes) {
41-
nativeInvoke(arg, null)
42-
} else {
39+
is ArrayBuffer -> {
40+
if (arg.isOwningBytes) {
41+
val buf = arg.bytes
42+
if (buf.position() != 0 || buf.limit() != buf.capacity()) {
4343
nativeInvoke(
4444
null,
45-
"expected an ArrayBuffer that owns its bytes; the bytes of a non-owning one are " +
46-
"no longer valid by the time the Promise resolves. Copy them with " +
47-
"ArrayBuffer.arrayBufferWithCopiedBytes().")
45+
"the ArrayBuffer's position must be 0 and its limit must equal its capacity; " +
46+
"position and limit are not preserved when the buffer is handed to " +
47+
"JavaScript.")
48+
return
4849
}
50+
nativeInvoke(arg, null)
51+
} else {
52+
nativeInvoke(
53+
null,
54+
"expected an ArrayBuffer that owns its bytes; the bytes of a non-owning one are " +
55+
"no longer valid by the time the Promise resolves. Copy them with " +
56+
"ArrayBuffer.arrayBufferWithCopiedBytes().")
57+
}
58+
}
4959
else -> nativeInvoke(null, "expected an ArrayBuffer or null, got ${arg.javaClass.name}")
5060
}
5161
}

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/blob/BlobModule.kt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,25 @@ public class BlobModule(reactContext: ReactApplicationContext) :
197197
return resolve(blob.getString("blobId"), blob.getInt("offset"), blob.getInt("size"))
198198
}
199199

200+
/**
201+
* Returns a newly allocated [ArrayBuffer] holding a copy of the requested range, or null if the
202+
* blob is unknown.
203+
*
204+
* The copy is required: the returned buffer is handed to JS as an ArrayBuffer, and Blobs are
205+
* immutable per the W3C File API — JS must not be able to write through to blob storage.
206+
*/
207+
public fun resolveBuffer(blobId: String?, offset: Int, size: Int): ArrayBuffer? {
208+
synchronized(blobs) {
209+
val data = blobs[blobId] ?: return null
210+
val length = if (size == -1) data.size - offset else size
211+
val copy = ArrayBuffer(length)
212+
val dest = copy.bytes
213+
dest.put(data, offset, length)
214+
dest.rewind()
215+
return copy
216+
}
217+
}
218+
200219
@Throws(IOException::class)
201220
private fun getBytesFromUri(contentUri: Uri): ByteArray {
202221
val inputStream =

0 commit comments

Comments
 (0)