Skip to content

Commit 551fa69

Browse files
committed
feat: accept ArrayBuffer and ArrayBufferView parts in Blob and File
1 parent 9ffa67b commit 551fa69

24 files changed

Lines changed: 1107 additions & 99 deletions

File tree

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
'use strict';
1212

13-
import type {BlobData, BlobOptions} from './BlobTypes';
13+
import type {BlobData, BlobOptions, BlobPart} from './BlobTypes';
1414

1515
/**
1616
* Opaque JS representation of some binary data in native.
@@ -54,10 +54,10 @@ class Blob {
5454

5555
/**
5656
* Constructor for JS consumers.
57-
* Currently we only support creating Blobs from other Blobs.
57+
* Accepts `Blob`, string, `ArrayBuffer`, and `ArrayBufferView` parts.
5858
* Reference: https://developer.mozilla.org/en-US/docs/Web/API/Blob/Blob
5959
*/
60-
constructor(parts: Array<Blob | string> = [], options?: BlobOptions) {
60+
constructor(parts: Array<BlobPart> = [], options?: BlobOptions) {
6161
const BlobManager = require('./BlobManager').default;
6262
this.data = BlobManager.createFromParts(parts, options).data;
6363
}

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

Lines changed: 56 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
*/
1010

1111
import typeof BlobT from './Blob';
12-
import type {BlobCollector, BlobData, BlobOptions} from './BlobTypes';
12+
import type {BlobCollector, BlobData, BlobOptions, BlobPart} from './BlobTypes';
13+
import type {BlobPart as NativeBlobPart} from './NativeBlobModule';
1314

1415
import NativeBlobModule from './NativeBlobModule';
1516
import invariant from 'invariant';
@@ -59,44 +60,66 @@ class BlobManager {
5960
/**
6061
* Create blob from existing array of blobs.
6162
*/
62-
static createFromParts(
63-
parts: Array<Blob | string>,
64-
options?: BlobOptions,
65-
): Blob {
63+
static createFromParts(parts: Array<BlobPart>, options?: BlobOptions): Blob {
6664
invariant(NativeBlobModule, 'NativeBlobModule is available.');
67-
6865
const blobId = uuidv4();
69-
const items = parts.map(part => {
70-
if (part instanceof ArrayBuffer || ArrayBuffer.isView(part)) {
71-
throw new Error(
72-
"Creating blobs from 'ArrayBuffer' and 'ArrayBufferView' are not supported",
73-
);
74-
}
66+
const binaryParts: Array<ArrayBuffer> = [];
67+
let size = 0;
68+
69+
const nativeParts: Array<NativeBlobPart> = [];
70+
71+
for (const part of parts) {
7572
if (part instanceof Blob) {
76-
return {
77-
data: part.data,
78-
type: 'blob',
79-
};
80-
} else {
81-
return {
82-
data: String(part),
83-
type: 'string',
84-
};
73+
size += part.size;
74+
nativeParts.push({type: 'blob', data: part.data});
75+
continue;
8576
}
86-
});
87-
const size = items.reduce((acc, curr) => {
88-
if (curr.type === 'string') {
89-
/* $FlowFixMe[incompatible-type] Natural Inference rollout. See
90-
* https://fburl.com/workplace/6291gfvu */
91-
return acc + global.unescape(encodeURI(curr.data)).length;
92-
} else {
93-
/* $FlowFixMe[prop-missing] Natural Inference rollout. See
94-
* https://fburl.com/workplace/6291gfvu */
95-
return acc + curr.data.size;
77+
78+
if (
79+
typeof part !== 'string' &&
80+
(part instanceof ArrayBuffer || ArrayBuffer.isView(part))
81+
) {
82+
const byteSize = part.byteLength;
83+
size += byteSize;
84+
85+
// A detached or empty buffer contributes no bytes, and `slice` throws on
86+
// a detached buffer — so there is nothing to send.
87+
if (byteSize === 0) {
88+
continue;
89+
}
90+
91+
const index = binaryParts.length;
92+
// Forwarded without copying here. A JS-heap `ArrayBuffer` is copied
93+
// during argument conversion, because `createFromParts` is asynchronous
94+
// — see `convertJSIArrayBufferToJArrayBuffer` and
95+
// `convertJSIArrayBufferToRCTArrayBuffer`. A native-backed one is
96+
// aliased instead, per the TurboModule zero-copy contract, so it is
97+
// snapshotted only once the call reaches the module thread.
98+
//
99+
// A whole buffer therefore goes as-is; only a partial view is sliced,
100+
// because the wire format carries whole buffers.
101+
let source: ArrayBuffer;
102+
if (part instanceof ArrayBuffer) {
103+
source = part;
104+
} else {
105+
const buffer = part.buffer;
106+
const byteOffset = part.byteOffset;
107+
source =
108+
byteOffset === 0 && byteSize === buffer.byteLength
109+
? buffer
110+
: buffer.slice(byteOffset, byteOffset + byteSize);
111+
}
112+
binaryParts.push(source);
113+
nativeParts.push({type: 'binaryPart', data: index});
114+
continue;
96115
}
97-
}, 0);
98116

99-
NativeBlobModule.createFromParts(items, blobId);
117+
const text = String(part);
118+
size += global.unescape(encodeURI(text)).length;
119+
nativeParts.push({type: 'string', data: text});
120+
}
121+
122+
NativeBlobModule.createFromParts(nativeParts, binaryParts, blobId);
100123

101124
return BlobManager.createFromOptions({
102125
blobId,

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,22 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @flow strict
7+
* @flow strict-local
88
* @format
99
*/
1010

1111
'use strict';
1212

13+
import type Blob from './Blob';
14+
1315
export opaque type BlobCollector = {...};
1416

17+
/**
18+
* A value accepted by the Blob and File constructors (W3C `BlobPart`).
19+
* https://w3c.github.io/FileAPI/#typedefdef-blobpart
20+
*/
21+
export type BlobPart = Blob | string | ArrayBuffer | $ArrayBufferView;
22+
1523
export type BlobData = {
1624
blobId: string,
1725
offset: number,

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

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
'use strict';
1212

13-
import type {BlobOptions} from './BlobTypes';
13+
import type {BlobOptions, BlobPart} from './BlobTypes';
1414

1515
import Blob from './Blob';
1616

@@ -23,11 +23,7 @@ class File extends Blob {
2323
/**
2424
* Constructor for JS consumers.
2525
*/
26-
constructor(
27-
parts: Array<Blob | string>,
28-
name: string,
29-
options?: BlobOptions,
30-
) {
26+
constructor(parts: Array<BlobPart>, name: string, options?: BlobOptions) {
3127
invariant(
3228
parts != null && name != null,
3329
'Failed to construct `File`: Must pass both `parts` and `name` arguments.',

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* LICENSE file in the root directory of this source tree.
66
*/
77

8+
#import <React/RCTArrayBuffer.h>
89
#import <React/RCTBridge.h>
910
#import <React/RCTBridgeModule.h>
1011
#import <React/RCTInitializing.h>
@@ -26,6 +27,8 @@
2627

2728
- (void)remove:(NSString *)blobId;
2829

29-
- (void)createFromParts:(NSArray<NSDictionary<NSString *, id> *> *)parts withId:(NSString *)blobId;
30+
- (void)createFromParts:(NSArray<NSDictionary<NSString *, id> *> *)parts
31+
binaryParts:(NSArray<RCTArrayBuffer *> *)binaryParts
32+
withId:(NSString *)blobId;
3033

3134
@end

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

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -178,26 +178,75 @@ - (void)removeWebSocketHandler:(double)socketID
178178
});
179179
}
180180

181-
// @lint-ignore FBOBJCUNTYPEDCOLLECTION1
182-
- (void)sendOverSocket:(NSDictionary *)blob socketID:(double)socketID
181+
- (void)sendOverSocket:(JS::NativeBlobModule::BlobDescriptor &)blob socketID:(double)socketID
183182
{
183+
NSString *blobId = blob.blobId();
184+
NSInteger offset = (NSInteger)blob.offset();
185+
NSInteger size = (NSInteger)blob.size();
186+
184187
dispatch_async(((RCTWebSocketModule *)[_moduleRegistry moduleForName:"WebSocketModule"]).methodQueue, ^{
185-
[[self->_moduleRegistry moduleForName:"WebSocketModule"] sendData:[self resolve:blob] forSocketID:@(socketID)];
188+
[[self->_moduleRegistry moduleForName:"WebSocketModule"] sendData:[self resolve:blobId offset:offset size:size]
189+
forSocketID:@(socketID)];
186190
});
187191
}
188192

189-
- (void)createFromParts:(NSArray<NSDictionary<NSString *, id> *> *)parts withId:(NSString *)blobId
193+
- (void)appendBlobRange:(NSDictionary<NSString *, id> *)blobDescriptor toData:(NSMutableData *)destination
194+
{
195+
NSString *blobId = [RCTConvert NSString:blobDescriptor[@"blobId"]];
196+
NSInteger offset = [RCTConvert NSInteger:blobDescriptor[@"offset"]];
197+
NSInteger size = [RCTConvert NSInteger:blobDescriptor[@"size"]];
198+
199+
NSData *stored;
200+
{
201+
std::lock_guard<std::mutex> lock(_blobsMutex);
202+
stored = _blobs[blobId];
203+
}
204+
205+
if (!stored) {
206+
[NSException raise:@"Invalid blob ID" format:@"blob %@ not found", blobId];
207+
return;
208+
}
209+
210+
NSInteger length = size == -1 ? (NSInteger)stored.length - offset : size;
211+
if (offset < 0 || length < 0 || offset + length > (NSInteger)stored.length) {
212+
[NSException raise:@"Invalid blob range"
213+
format:@"offset %ld, length %ld exceeds blob size %lu",
214+
(long)offset,
215+
(long)length,
216+
(unsigned long)stored.length];
217+
return;
218+
}
219+
220+
[destination appendBytes:(const uint8_t *)stored.bytes + offset length:(NSUInteger)length];
221+
}
222+
223+
- (void)createFromParts:(NSArray<NSDictionary<NSString *, id> *> *)parts
224+
binaryParts:(NSArray<RCTArrayBuffer *> *)binaryParts
225+
withId:(NSString *)blobId
190226
{
191227
NSMutableData *data = [NSMutableData new];
192228
for (NSDictionary<NSString *, id> *part in parts) {
193229
NSString *type = [RCTConvert NSString:part[@"type"]];
194230

195231
if ([type isEqualToString:@"blob"]) {
196-
NSData *partData = [self resolve:part[@"data"]];
197-
[data appendData:partData];
232+
[self appendBlobRange:part[@"data"] toData:data];
198233
} else if ([type isEqualToString:@"string"]) {
199234
NSData *partData = [[RCTConvert NSString:part[@"data"]] dataUsingEncoding:NSUTF8StringEncoding];
200235
[data appendData:partData];
236+
} else if ([type isEqualToString:@"binaryPart"]) {
237+
NSInteger index = [RCTConvert NSInteger:part[@"data"]];
238+
if (index < 0 || index >= (NSInteger)binaryParts.count) {
239+
[NSException raise:@"Invalid binary part index for blob"
240+
format:@"%ld is out of range for %lu binary parts", (long)index, (unsigned long)binaryParts.count];
241+
}
242+
RCTArrayBuffer *binaryPart = binaryParts[index];
243+
if (![binaryPart isKindOfClass:[RCTArrayBuffer class]]) {
244+
[NSException raise:@"Invalid binary part for blob"
245+
format:@"binary part %ld is %@, expected RCTArrayBuffer", (long)index, [binaryPart class]];
246+
}
247+
if (binaryPart.length > 0) {
248+
[data appendBytes:binaryPart.mutableBytes length:binaryPart.length];
249+
}
201250
} else {
202251
[NSException raise:@"Invalid type for blob" format:@"%@ is invalid", type];
203252
}

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

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

11-
const BlobModule = {
12-
createFromParts() {},
13-
release() {},
11+
const BlobModule: {
12+
createFromParts: JestMockFn<[Array<{...}>, Array<ArrayBuffer>, string], void>,
13+
release: JestMockFn<[string], void>,
14+
} = {
15+
createFromParts: jest.fn(),
16+
release: jest.fn(),
1417
};
1518

1619
export default BlobModule;

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

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,14 @@ jest.mock('../../BatchedBridge/NativeModules', () => ({
1717
},
1818
}));
1919

20+
const MockBlobModule = require('../__mocks__/BlobModule').default;
2021
const Blob = require('../Blob').default;
2122

2223
describe('Blob', function () {
24+
beforeEach(() => {
25+
MockBlobModule.createFromParts.mockClear();
26+
});
27+
2328
it('should create empty blob', () => {
2429
const blob = new Blob();
2530
expect(blob).toBeInstanceOf(Blob);
@@ -58,6 +63,86 @@ describe('Blob', function () {
5863
expect(blob.type).toBe('');
5964
});
6065

66+
it('should send array buffer and typed array parts as binary parts', () => {
67+
const bytes = Uint8Array.from([10, 20, 30, 40]);
68+
const blob = new Blob([bytes.buffer, bytes.subarray(1, 3)]);
69+
70+
expect(blob.size).toBe(6);
71+
72+
const [parts, binaryParts] = MockBlobModule.createFromParts.mock.calls[0];
73+
74+
expect(parts).toEqual([
75+
{type: 'binaryPart', data: 0},
76+
{type: 'binaryPart', data: 1},
77+
]);
78+
expect(binaryParts.map(b => Array.from(new Uint8Array(b)))).toEqual([
79+
[10, 20, 30, 40],
80+
[20, 30],
81+
]);
82+
});
83+
84+
it('should preserve part ordering across mixed types', () => {
85+
const inner = new Blob(['D']);
86+
MockBlobModule.createFromParts.mockClear();
87+
88+
const blob = new Blob(['A', Uint8Array.from([66, 67]), inner]);
89+
expect(blob.size).toBe(4);
90+
91+
const [parts, binaryParts] = MockBlobModule.createFromParts.mock.calls[0];
92+
93+
expect(parts).toEqual([
94+
{type: 'string', data: 'A'},
95+
{type: 'binaryPart', data: 0},
96+
{type: 'blob', data: inner.data},
97+
]);
98+
expect(binaryParts.map(b => Array.from(new Uint8Array(b)))).toEqual([
99+
[66, 67],
100+
]);
101+
});
102+
103+
it('should handle empty array buffer parts', () => {
104+
expect(new Blob([new ArrayBuffer(0)]).size).toBe(0);
105+
});
106+
107+
it('should count Float64Array and DataView parts in bytes', () => {
108+
const f64 = new Float64Array([1.5, 2.5, 3.5]);
109+
expect(new Blob([f64]).size).toBe(24);
110+
expect(new Blob([new DataView(f64.buffer, 8, 8)]).size).toBe(8);
111+
});
112+
113+
it('should treat a detached ArrayBuffer as an empty blob part', () => {
114+
const ab = new ArrayBuffer(8);
115+
// $FlowFixMe[cannot-resolve-name] Node's structuredClone is not in RN's Flow libs.
116+
structuredClone(ab, {transfer: [ab]});
117+
const blob = new Blob([ab]);
118+
expect(blob.size).toBe(0);
119+
});
120+
121+
it('should treat a detached ArrayBufferView as an empty blob part', () => {
122+
const ab = new ArrayBuffer(8);
123+
const view = new Uint8Array(ab, 2, 4);
124+
// $FlowFixMe[cannot-resolve-name] Node's structuredClone is not in RN's Flow libs.
125+
structuredClone(ab, {transfer: [ab]});
126+
const blob = new Blob([view]);
127+
expect(blob.size).toBe(0);
128+
});
129+
130+
it('stringifies parts that are neither Blob nor BufferSource (W3C: USVString)', () => {
131+
// $FlowExpectedError[incompatible-type]
132+
expect(new Blob([42]).size).toBe(2);
133+
// $FlowExpectedError[incompatible-type]
134+
expect(new Blob([null]).size).toBe(4);
135+
// $FlowExpectedError[incompatible-type]
136+
expect(new Blob([undefined]).size).toBe(9);
137+
// $FlowExpectedError[incompatible-type]
138+
expect(new Blob([{}]).size).toBe(15);
139+
// $FlowExpectedError[incompatible-type]
140+
expect(new Blob([new String('abc')]).size).toBe(3); // eslint-disable-line no-new-wrappers
141+
142+
const [parts] = MockBlobModule.createFromParts.mock.calls[0];
143+
expect(parts).toEqual([{type: 'string', data: '42'}]);
144+
});
145+
61146
it('should slice a blob', () => {
62147
const blob = new Blob();
63148

0 commit comments

Comments
 (0)