Skip to content

Commit

Permalink
BlobManager: implement Blob from ArrayBuffer (facebook#39276)
Browse files Browse the repository at this point in the history
Summary:
Fixes a critical networking path used commonly in graphics code, among many other use-cases. It is commonplace to store images within a single binary such as a GLB (see [KHR_binary_glTF](https://github.com/KhronosGroup/glTF/blob/main/extensions/1.0/Khronos/KHR_binary_glTF/README.md#overview)). Base64 encoded glTF can be critically slow in older browsers with an increased size, so it's not a suitable fallback for those targeting multiple platforms. Furthermore, large base64 payloads can cause crash behavior on native when passed via JSI (e.g. `expo-gl`). For textures, this can be several megabytes of high precision data, and a uri from a blob is preferred rather than writing platform-specific workarounds via the filesystem.

A user-land patch I've employed in [pmndrs/react-three-fiber](https://github.com/pmndrs/react-three-fiber), as well as [existing user-land patches](https://github.com/mrousavy/react-native-blob-jsi-helper), is to test the `Blob` constructor with an empty `ArrayBuffer`. `base64-js` is used here since it's already installed with `react-native` for binary utils elsewhere. This is declared as a dependency in the linked library since this is an implementation detail, but will be de-duplicated at install time.

```js
import { fromByteArray } from 'base64-js'

// Patch Blob for ArrayBuffer if unsupported
try {
  new Blob([new ArrayBuffer(4)])
} catch (_) {
  global.Blob = class extends Blob {
    constructor(parts, options) {
      super(
        parts?.map((part) => {
          if (part instanceof ArrayBuffer || ArrayBuffer.isView(part)) {
            part = fromByteArray(new Uint8Array(part))
          }

          return part
        }),
        options,
      )
    }
  }
}
```

## Changelog:

[INTERNAL] [FIXED] - Implement Blob from ArrayBuffer

Pull Request resolved: facebook#39276

Test Plan:
Run the following at the project root to selectively test changes:

`jest packages/react-native/Libraries/Blob`

> **Note**: base64 encoding will add an additional ~33% size increase to binary data as part of this PR. This is padded to 4 bytes so a byte length of 4 will encode as 8. Native `ArrayBuffer` support would not have this behavior and remove the overhead of base64 encoding back and forth. This is a growing behavior across the networking stack, such as in `FileReader`.

Reviewed By: NickGerleman

Differential Revision: D48954160

Pulled By: dmytrorykun

fbshipit-source-id: 9d3b984aefe16bad2ee1088140b6e1160df52f55
  • Loading branch information
CodyJasonBennett authored and facebook-github-bot committed Sep 7, 2023
1 parent 1b78da8 commit a228b0f
Show file tree
Hide file tree
Showing 4 changed files with 24 additions and 15 deletions.
5 changes: 4 additions & 1 deletion packages/react-native/Libraries/Blob/Blob.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,10 @@ class Blob {
* Currently we only support creating Blobs from other Blobs.
* Reference: https://developer.mozilla.org/en-US/docs/Web/API/Blob/Blob
*/
constructor(parts: Array<Blob | string> = [], options?: BlobOptions) {
constructor(
parts: Array<$ArrayBufferView | ArrayBuffer | Blob | string> = [],
options?: BlobOptions,
) {
const BlobManager = require('./BlobManager');
this.data = BlobManager.createFromParts(parts, options).data;
}
Expand Down
19 changes: 9 additions & 10 deletions packages/react-native/Libraries/Blob/BlobManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import type {BlobCollector, BlobData, BlobOptions} from './BlobTypes';

import NativeBlobModule from './NativeBlobModule';
import {fromByteArray} from 'base64-js';
import invariant from 'invariant';

const Blob = require('./Blob');
Expand Down Expand Up @@ -59,22 +60,20 @@ class BlobManager {
* Create blob from existing array of blobs.
*/
static createFromParts(
parts: Array<Blob | string>,
parts: Array<$ArrayBufferView | ArrayBuffer | Blob | string>,
options?: BlobOptions,
): Blob {
invariant(NativeBlobModule, 'NativeBlobModule is available.');

const blobId = uuidv4();
const items = parts.map(part => {
if (
part instanceof ArrayBuffer ||
(global.ArrayBufferView && part instanceof global.ArrayBufferView)
) {
throw new Error(
"Creating blobs from 'ArrayBuffer' and 'ArrayBufferView' are not supported",
);
}
if (part instanceof Blob) {
if (part instanceof ArrayBuffer || ArrayBuffer.isView(part)) {
return {
// $FlowFixMe[incompatible-cast]
data: fromByteArray(new Uint8Array((part: ArrayBuffer))),
type: 'string',
};
} else if (part instanceof Blob) {
return {
data: part.data,
type: 'blob',
Expand Down
2 changes: 1 addition & 1 deletion packages/react-native/Libraries/Blob/File.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ class File extends Blob {
* Constructor for JS consumers.
*/
constructor(
parts: Array<Blob | string>,
parts: Array<$ArrayBufferView | ArrayBuffer | Blob | string>,
name: string,
options?: BlobOptions,
) {
Expand Down
13 changes: 10 additions & 3 deletions packages/react-native/Libraries/Blob/__tests__/Blob-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ jest.setMock('../../BatchedBridge/NativeModules', {
});

const Blob = require('../Blob');
const {fromByteArray} = require('base64-js');

describe('Blob', function () {
it('should create empty blob', () => {
Expand All @@ -26,7 +27,7 @@ describe('Blob', function () {
expect(blob.type).toBe('');
});

it('should create blob from other blobs and strings', () => {
it('should create blob from ArrayBuffer, other blobs, strings', () => {
const blobA = new Blob();
const blobB = new Blob();
const textA = 'i \u2665 dogs';
Expand All @@ -43,14 +44,20 @@ describe('Blob', function () {
blobA.data.size = 34540;
blobB.data.size = 65452;

const blob = new Blob([blobA, blobB, textA, textB, textC]);
const buffer = new ArrayBuffer(4);

const blob = new Blob([blobA, blobB, textA, textB, textC, buffer]);

expect(blob.size).toBe(
blobA.size +
blobB.size +
global.Buffer.byteLength(textA, 'UTF-8') +
global.Buffer.byteLength(textB, 'UTF-8') +
global.Buffer.byteLength(textC, 'UTF-8'),
global.Buffer.byteLength(textC, 'UTF-8') +
global.Buffer.byteLength(
fromByteArray(new Uint8Array(buffer)),
'UTF-8',
),
);
expect(blob.type).toBe('');
});
Expand Down

0 comments on commit a228b0f

Please sign in to comment.