From 3a3824ba84c5dcd8ef2bd328798e845a6e4f32eb Mon Sep 17 00:00:00 2001 From: Johnathan Simeroth Date: Sat, 13 Sep 2025 19:00:01 +0100 Subject: [PATCH 1/3] fix: implement uploadBytes for storage modular sdk --- packages/storage/lib/modular.ts | 45 +++++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/packages/storage/lib/modular.ts b/packages/storage/lib/modular.ts index f7af854a22..862eea92ff 100644 --- a/packages/storage/lib/modular.ts +++ b/packages/storage/lib/modular.ts @@ -30,6 +30,7 @@ import type { UploadMetadata, EmulatorMockTokenOptions, } from './types/storage'; +import { TaskEvent, TaskState } from './types/storage'; import type { StorageReferenceInternal, StorageInternal } from './types/internal'; type WithModularDeprecationArg = F extends (...args: infer P) => infer R @@ -267,18 +268,46 @@ export function updateMetadata( } /** - * Uploads data to this object's location. The upload is not resumable. - * @param _storageRef - Storage `Reference` instance. - * @param _data - The data (Blob | Uint8Array | ArrayBuffer) to upload to the storage bucket at the reference location. - * @param _metadata - A Storage `UploadMetadata` instance to update. Optional. + * Uploads data to this object's location. The upload is not resumable. If the upload is canceled, + * the Promise will reject with the TaskSnapshot. If there is an error it will reject with the StorageError + * @param storageRef - Storage `Reference` instance. + * @param data - The data (Blob | Uint8Array | ArrayBuffer) to upload to the storage bucket at the reference location. + * @param metadata - A Storage `UploadMetadata` instance to update. Optional. * @returns {Promise} */ export async function uploadBytes( - _storageRef: StorageReference, - _data: Blob | Uint8Array | ArrayBuffer, - _metadata?: UploadMetadata, + storageRef: StorageReference, + data: Blob | Uint8Array | ArrayBuffer, + metadata?: UploadMetadata, ): Promise { - throw new Error('`uploadBytes()` is not implemented'); + const task = uploadBytesResumable(storageRef, data, metadata); + return new Promise((resolve, reject) => { + task.on( + TaskEvent.STATE_CHANGED, + taskSnapshot => { + switch (taskSnapshot.state) { + case TaskState.RUNNING: + break; + case TaskState.PAUSED: + task.resume(); + break; + case TaskState.SUCCESS: + resolve({ ref: taskSnapshot.ref, metadata: taskSnapshot.metadata }); + break; + case TaskState.CANCELED: + // The TaskSnapshot may be useful to have if we reject due to cancel + reject(taskSnapshot); + break; + case TaskState.ERROR: + // this will be handled in the dedicated error listener + break; + default: + throw new Error(`Unhandled task state in uploadBytes: ${taskSnapshot.state}`); + } + }, + error => reject(error), + ); + }); } /** From 734019b10dd4d5b69e70897827930fb22ab4f1e5 Mon Sep 17 00:00:00 2001 From: Mike Hardy Date: Fri, 1 May 2026 17:19:45 -0500 Subject: [PATCH 2/3] fix(storage): uploadBytes now with upstream types, tests --- .../compare-types/packages/storage/config.ts | 7 --- packages/storage/e2e/StorageTask.e2e.js | 39 +++++++++++++++ packages/storage/lib/modular.ts | 47 ++++++++++++++----- packages/storage/type-test.ts | 13 ++++- 4 files changed, 87 insertions(+), 19 deletions(-) diff --git a/.github/scripts/compare-types/packages/storage/config.ts b/.github/scripts/compare-types/packages/storage/config.ts index f880e0ca63..28631c88c0 100644 --- a/.github/scripts/compare-types/packages/storage/config.ts +++ b/.github/scripts/compare-types/packages/storage/config.ts @@ -166,13 +166,6 @@ const config: PackageConfig = { '(the Web Streams API type). The Node.js stream type is used because the ' + 'React Native environment does not have the Web Streams API.', }, - { - name: 'uploadBytes', - reason: - 'Returns `Promise` in RN Firebase instead of `Promise`. ' + - '`TaskResult` is a type alias for `UploadResult`, so the runtime shape is identical; ' + - 'the different name is for consistency with the native task system.', - }, { name: 'uploadBytesResumable', reason: diff --git a/packages/storage/e2e/StorageTask.e2e.js b/packages/storage/e2e/StorageTask.e2e.js index 6d942d40a4..ec6da3d2ea 100644 --- a/packages/storage/e2e/StorageTask.e2e.js +++ b/packages/storage/e2e/StorageTask.e2e.js @@ -1070,6 +1070,45 @@ describe('storage() -> StorageTask', function () { }); }); + describe('uploadBytes()', function () { + it('resolves with ref and metadata whose size matches uploaded bytes', async function () { + const { getStorage, ref, uploadBytes } = storageModular; + const jsonDerulo = JSON.stringify({ foo: 'bar' }); + const expectedByteLength = jsonDerulo.length; + + const arrayBuffer = new ArrayBuffer(jsonDerulo.length); + const arrayBufferView = new Uint8Array(arrayBuffer); + + for (let i = 0, strLen = jsonDerulo.length; i < strLen; i++) { + arrayBufferView[i] = jsonDerulo.charCodeAt(i); + } + + const uploadResult = await uploadBytes( + ref(getStorage(), `${PATH}/uploadBytesModular.json`), + arrayBuffer, + { + contentType: 'application/json', + }, + ); + + uploadResult.ref.fullPath.should.containEql('uploadBytesModular.json'); + uploadResult.metadata.should.be.an.Object(); + uploadResult.metadata.size.should.eql(expectedByteLength); + uploadResult.metadata.contentType.should.eql('application/json'); + }); + + it('rejects when metadata is not an object', async function () { + const { getStorage, ref, uploadBytes } = storageModular; + try { + await uploadBytes(ref(getStorage(), `${PATH}/uploadBytesBadMeta.json`), new ArrayBuffer(), 123); + return Promise.reject(new Error('Did not error!')); + } catch (error) { + error.message.should.containEql('must be an object value'); + return Promise.resolve(); + } + }); + }); + describe('upload tasks', function () { // before(async function () { // // TODO we need some semi-large assets to upload and download I think? diff --git a/packages/storage/lib/modular.ts b/packages/storage/lib/modular.ts index 862eea92ff..e4ed186950 100644 --- a/packages/storage/lib/modular.ts +++ b/packages/storage/lib/modular.ts @@ -17,6 +17,7 @@ import { getApp } from '@react-native-firebase/app'; import { MODULAR_DEPRECATION_ARG } from '@react-native-firebase/app/dist/module/common'; +import { NativeFirebaseError } from '@react-native-firebase/app/dist/module/internal'; import type { FirebaseApp } from '@react-native-firebase/app'; import type { FirebaseStorage, @@ -24,11 +25,11 @@ import type { FullMetadata, ListResult, ListOptions, - TaskResult, Task, SettableMetadata, UploadMetadata, EmulatorMockTokenOptions, + UploadResult, } from './types/storage'; import { TaskEvent, TaskState } from './types/storage'; import type { StorageReferenceInternal, StorageInternal } from './types/internal'; @@ -269,43 +270,67 @@ export function updateMetadata( /** * Uploads data to this object's location. The upload is not resumable. If the upload is canceled, - * the Promise will reject with the TaskSnapshot. If there is an error it will reject with the StorageError + * the Promise rejects with a {@link NativeFirebaseError} (typically `storage/cancelled`), + * matching other storage upload tasks. Other failures reject with the same error type as {@link uploadBytesResumable}. * @param storageRef - Storage `Reference` instance. * @param data - The data (Blob | Uint8Array | ArrayBuffer) to upload to the storage bucket at the reference location. * @param metadata - A Storage `UploadMetadata` instance to update. Optional. - * @returns {Promise} + * @returns {Promise} */ export async function uploadBytes( storageRef: StorageReference, data: Blob | Uint8Array | ArrayBuffer, metadata?: UploadMetadata, -): Promise { +): Promise { const task = uploadBytesResumable(storageRef, data, metadata); return new Promise((resolve, reject) => { - task.on( + let completed = false; + const subscription: { unsubscribe?: () => void } = {}; + + const settle = (fn: () => void) => { + if (completed) { + return; + } + completed = true; + subscription.unsubscribe?.(); + fn(); + }; + + subscription.unsubscribe = task.on( TaskEvent.STATE_CHANGED, taskSnapshot => { switch (taskSnapshot.state) { case TaskState.RUNNING: break; case TaskState.PAUSED: + // we are wrapping the resumable version, just resume if it pauses task.resume(); break; case TaskState.SUCCESS: - resolve({ ref: taskSnapshot.ref, metadata: taskSnapshot.metadata }); + settle(() => resolve({ ref: taskSnapshot.ref, metadata: taskSnapshot.metadata })); break; case TaskState.CANCELED: - // The TaskSnapshot may be useful to have if we reject due to cancel - reject(taskSnapshot); + settle(() => + reject( + NativeFirebaseError.fromEvent( + { code: 'cancelled', message: 'User cancelled the operation.' }, + 'storage', + ), + ), + ); break; case TaskState.ERROR: - // this will be handled in the dedicated error listener + // this is handled in the dedicated error listener below break; default: - throw new Error(`Unhandled task state in uploadBytes: ${taskSnapshot.state}`); + settle(() => + reject(new Error(`Unhandled task state in uploadBytes: ${taskSnapshot.state}`)), + ); } }, - error => reject(error), + error => { + settle(() => reject(error)); + }, ); }); } diff --git a/packages/storage/type-test.ts b/packages/storage/type-test.ts index b5b753752e..551ce1cdc2 100644 --- a/packages/storage/type-test.ts +++ b/packages/storage/type-test.ts @@ -3,6 +3,7 @@ import storage, { // Types type FirebaseStorage, type FirebaseStorageTypes, + type StorageReference, // Modular API getStorage, connectStorageEmulator, @@ -234,10 +235,20 @@ updateMetadata(modularRef1, { cacheControl: 'no-cache' }).then( uploadBytes(modularRef1, new Blob(), { cacheControl: 'no-cache' }).then( (result: TaskResult) => { - console.log(result); + const uploadBytesRef: StorageReference = result.ref; + const uploadBytesMeta: FullMetadata = result.metadata; + console.log(uploadBytesRef.fullPath, uploadBytesMeta.size); }, ); +uploadBytes(modularRef1, new Uint8Array([1, 2, 3])).then((result: TaskResult) => { + console.log(result.ref.name, result.metadata.size); +}); + +uploadBytes(modularRef1, new ArrayBuffer(0)).then((result: TaskResult) => { + console.log(result.metadata.fullPath); +}); + const modularUploadBytesResumable = uploadBytesResumable(modularRef1, new Blob(), { cacheControl: 'no-cache', }); From 8eb4ae1c1d8905060cc8cc6f9115bcc4d2bc28da Mon Sep 17 00:00:00 2001 From: Mike Hardy Date: Mon, 4 May 2026 21:57:31 -0500 Subject: [PATCH 3/3] fix(storage, other): work around Hermes Blob / firebase-js-sdk mal-interaction Hermes Blob implementation is lacking, and breaks storage uploads done using firebase-js-sdk which expects a full-featured implementation if "Blob" exists on globalThis if you temporarily hide the Blob implementation firebase-js-sdk will fallback to a different data upload method, then binary storage uploads will work So, make a custom binary upload pathway for storage when using firebase-js-sdk and hide Blob in it, but only if platform is not in fact web --- packages/storage/e2e/StorageTask.e2e.js | 15 +- packages/storage/lib/StorageReference.ts | 34 +++ packages/storage/lib/types/internal.ts | 10 + packages/storage/lib/web/RNFBStorageModule.ts | 249 ++++++++++-------- 4 files changed, 197 insertions(+), 111 deletions(-) diff --git a/packages/storage/e2e/StorageTask.e2e.js b/packages/storage/e2e/StorageTask.e2e.js index ec6da3d2ea..6cc5ab8f86 100644 --- a/packages/storage/e2e/StorageTask.e2e.js +++ b/packages/storage/e2e/StorageTask.e2e.js @@ -1076,16 +1076,11 @@ describe('storage() -> StorageTask', function () { const jsonDerulo = JSON.stringify({ foo: 'bar' }); const expectedByteLength = jsonDerulo.length; - const arrayBuffer = new ArrayBuffer(jsonDerulo.length); - const arrayBufferView = new Uint8Array(arrayBuffer); - - for (let i = 0, strLen = jsonDerulo.length; i < strLen; i++) { - arrayBufferView[i] = jsonDerulo.charCodeAt(i); - } + const jsonDeruloBytes = new Uint8Array([...jsonDerulo].map(char => char.charCodeAt(0))); const uploadResult = await uploadBytes( ref(getStorage(), `${PATH}/uploadBytesModular.json`), - arrayBuffer, + jsonDeruloBytes, { contentType: 'application/json', }, @@ -1100,7 +1095,11 @@ describe('storage() -> StorageTask', function () { it('rejects when metadata is not an object', async function () { const { getStorage, ref, uploadBytes } = storageModular; try { - await uploadBytes(ref(getStorage(), `${PATH}/uploadBytesBadMeta.json`), new ArrayBuffer(), 123); + await uploadBytes( + ref(getStorage(), `${PATH}/uploadBytesBadMeta.json`), + new ArrayBuffer(), + 123, + ); return Promise.reject(new Error('Did not error!')); } catch (error) { error.message.should.containEql('must be an object value'); diff --git a/packages/storage/lib/StorageReference.ts b/packages/storage/lib/StorageReference.ts index e14e10b93c..a8e5a122e2 100644 --- a/packages/storage/lib/StorageReference.ts +++ b/packages/storage/lib/StorageReference.ts @@ -47,6 +47,25 @@ import type { } from './types/storage'; import type { ListResultInternal, StorageInternal } from './types/internal'; +async function putPayloadToUint8Array(data: Blob | Uint8Array | ArrayBuffer): Promise { + if (data instanceof Uint8Array) { + return data.byteOffset === 0 && data.byteLength === data.buffer.byteLength + ? data + : new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + } + if (data instanceof ArrayBuffer) { + return new Uint8Array(data); + } + return new Uint8Array( + await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as ArrayBuffer); + reader.onerror = () => reject(reader.error); + reader.readAsArrayBuffer(data); + }), + ); +} + export default class Reference extends ReferenceBase implements StorageReference { _storage: StorageInternal; @@ -206,6 +225,21 @@ export default class Reference extends ReferenceBase implements StorageReference put(data: Blob | Uint8Array | ArrayBuffer, metadata?: UploadMetadata): Task { const validatedMetadata = isUndefined(metadata) ? metadata : validateMetadata(metadata, false); + // Firebase-js fallback (e.g. macOS) exposes `uploadBinary`; iOS/Android native omit it. + const uploadBinary = this._storage.native.uploadBinary; + if (uploadBinary) { + return new StorageUploadTask(this, async task => { + const bytes = await putPayloadToUint8Array(data); + return uploadBinary.call( + this._storage.native, + this.toString(), + bytes, + validatedMetadata, + task._id, + ); + }); + } + return new StorageUploadTask(this, task => Base64.fromData(data).then(({ string, format }) => { const { _string, _format, _metadata } = this._updateString( diff --git a/packages/storage/lib/types/internal.ts b/packages/storage/lib/types/internal.ts index 196ab98e1b..c31e2ca71f 100644 --- a/packages/storage/lib/types/internal.ts +++ b/packages/storage/lib/types/internal.ts @@ -158,12 +158,22 @@ export interface RNFBStorageModule { metadata: UploadMetadata | undefined, taskId: number, ): Promise; + + /** Optional on firebase-js fallback only; native modules use base64 + {@link putString}. */ + uploadBinary?( + url: string, + data: Uint8Array, + metadata: UploadMetadata | undefined, + taskId: number, + ): Promise; + putFile( url: string, filePath: string, metadata: UploadMetadata | undefined, taskId: number, ): Promise; + writeToFile(url: string, filePath: string, taskId: number): Promise; /** diff --git a/packages/storage/lib/web/RNFBStorageModule.ts b/packages/storage/lib/web/RNFBStorageModule.ts index 62d1486f97..ca0f054cfb 100644 --- a/packages/storage/lib/web/RNFBStorageModule.ts +++ b/packages/storage/lib/web/RNFBStorageModule.ts @@ -9,18 +9,19 @@ import { list, listAll, updateMetadata, - uploadBytesResumable, + uploadBytes, ref as firebaseStorageRef, } from '@react-native-firebase/app/dist/module/internal/web/firebaseStorage'; import type { StorageReference, - UploadTask, UploadTaskSnapshot as FirebaseUploadTaskSnapshot, FullMetadata, ListResult as FirebaseListResult, } from '@react-native-firebase/app/dist/module/internal/web/firebaseStorage'; import type { FirebaseApp } from '@react-native-firebase/app/dist/module/internal/web/firebaseApp'; +import { Platform } from 'react-native'; + import { guard, getWebError, @@ -157,6 +158,103 @@ function makeSettableMetadata(metadata: SettableMetadata): SettableMetadata { }; } +/** Binary string from `Base64.atob` → contiguous bytes. */ +function binaryStringToArrayBuffer(binary: string): ArrayBuffer { + const len = binary.length; + const u8 = new Uint8Array(len); + for (let i = 0; i < len; i++) { + u8[i] = binary.charCodeAt(i) & 0xff; + } + return u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength); +} + +/** + * `@firebase/storage` multipart uploads call `new Blob([ … ])`. On React Native with Hermes + * runtimes (all but `Platform.OS === 'web'`)—that global `Blob` mishandles binary parts, + * so the wrong bytes are stored. Temporarily hiding `Blob` forces the SDK’s pure-JS merge path. + */ +async function runWithFirebaseStorageMultipartBlobFix(work: () => Promise): Promise { + if (Platform.OS === 'web') { + return work(); + } + const savedBlob = globalThis.Blob; + try { + Reflect.deleteProperty(globalThis, 'Blob'); + return await work(); + } finally { + if (savedBlob !== undefined) { + globalThis.Blob = savedBlob; + } + } +} + +/** `uploadBytes` plus `storage_event` emissions expected by the RNFB task bridge. */ +async function firebaseUploadArrayBuffer( + ref: StorageReference, + buffer: ArrayBuffer, + uploadMetadata: SettableMetadata & { md5Hash?: string | undefined }, + appName: string, + taskId: string, +): Promise { + const byteLength = buffer.byteLength; + emitEvent('storage_event', { + body: { + totalBytes: byteLength, + bytesTransferred: 0, + state: 'running', + metadata: {}, + }, + appName, + taskId, + eventName: 'state_changed', + }); + try { + const result = await runWithFirebaseStorageMultipartBlobFix(() => + uploadBytes(ref, buffer, uploadMetadata), + ); + const snapshot = { + totalBytes: byteLength, + bytesTransferred: byteLength, + state: 'success', + metadata: result.metadata, + ref: result.ref, + } as FirebaseUploadTaskSnapshot; + const event = { + body: { + ...uploadTaskSnapshotToObject(snapshot), + state: 'success', + }, + appName, + taskId, + eventName: 'state_changed', + }; + emitEvent('storage_event', event); + emitEvent('storage_event', { + ...event, + eventName: 'upload_success', + }); + return uploadTaskSnapshotToObject(snapshot); + } catch (error) { + const err = error as Error; + const errorSnapshot = uploadTaskErrorToObject(err, null); + const event = { + body: { + ...errorSnapshot, + state: 'error', + }, + appName, + taskId, + eventName: 'state_changed', + }; + emitEvent('storage_event', event); + emitEvent('storage_event', { + ...event, + eventName: 'upload_failure', + }); + throw err; + } +} + function listResultToObject(result: FirebaseListResult) { return { nextPageToken: result.nextPageToken ?? undefined, @@ -168,7 +266,6 @@ function listResultToObject(result: FirebaseListResult) { const emulatorForApp: Record = {}; const appInstances: Record = {}; const storageInstances: Record = {}; -const tasks: Record = {}; function getBucketFromUrl(url: string): string { // Check if the URL starts with "gs://" @@ -406,6 +503,38 @@ export default { ); }, + /** + * Firebase-js-sdk upload for raw bytes (web fallback only; native uses `putString` after base64). + * + * @param appName - The app name. + * @param url - The path to the object. + * @param data - Decoded upload payload. + * @param metadata - The metadata (SettableMetadata). + * @param taskId - The task ID. + * @return {Promise} The upload snapshot. + */ + uploadBinary( + appName: string, + url: string, + data: Uint8Array, + metadata: SettableMetadata = {}, + taskId: string, + ): Promise { + return guard(async () => { + const ref = getReferenceFromUrl(appName, url); + const payload = new Uint8Array(data); + const buffer = payload.buffer.slice( + payload.byteOffset, + payload.byteOffset + payload.byteLength, + ); + const uploadMetadata = { + ...makeSettableMetadata(metadata), + md5Hash: metadata.md5Hash, + }; + return firebaseUploadArrayBuffer(ref, buffer, uploadMetadata, appName, taskId); + }); + }, + /** * Put a string to the path. * @param appName - The app name. @@ -426,81 +555,27 @@ export default { ): Promise { return guard(async () => { const ref = getReferenceFromUrl(appName, url); - let decodedString: string | null = null; + let decodedBinary: string | null = null; - // This is always either base64 or base64url switch (format) { case 'base64': - decodedString = Base64.atob(string); + decodedBinary = Base64.atob(string); break; case 'base64url': - decodedString = Base64.atob(string.replace(/_/g, '/').replace(/-/g, '+')); + decodedBinary = Base64.atob(string.replace(/_/g, '/').replace(/-/g, '+')); break; + default: + throw new Error( + `firebase.storage putString: unsupported format '${format}' on this platform.`, + ); } - const arrayBuffer = new Uint8Array([...decodedString!].map(c => c.charCodeAt(0))); - - const task = uploadBytesResumable(ref, arrayBuffer, { + const buffer = binaryStringToArrayBuffer(decodedBinary); + const uploadMetadata = { ...makeSettableMetadata(metadata), md5Hash: metadata.md5Hash, - }); - - // Store the task in the tasks map. - tasks[taskId] = task; - - const snapshot = await new Promise((resolve, reject) => { - task.on( - 'state_changed', - (snapshot: FirebaseUploadTaskSnapshot) => { - const event = { - body: uploadTaskSnapshotToObject(snapshot), - appName, - taskId, - eventName: 'state_changed', - }; - emitEvent('storage_event', event); - }, - (error: Error) => { - const errorSnapshot = uploadTaskErrorToObject(error, task.snapshot); - const event = { - body: { - ...errorSnapshot, - state: 'error', - }, - appName, - taskId, - eventName: 'state_changed', - }; - emitEvent('storage_event', event); - emitEvent('storage_event', { - ...event, - eventName: 'upload_failure', - }); - delete tasks[taskId]; - reject(error); - }, - () => { - delete tasks[taskId]; - const event = { - body: { - ...uploadTaskSnapshotToObject(task.snapshot), - state: 'success', - }, - appName, - taskId, - eventName: 'state_changed', - }; - emitEvent('storage_event', event); - emitEvent('storage_event', { - ...event, - eventName: 'upload_success', - }); - resolve(task.snapshot); - }, - ); - }); - - return uploadTaskSnapshotToObject(snapshot); + }; + return firebaseUploadArrayBuffer(ref, buffer, uploadMetadata, appName, taskId); }); }, @@ -518,40 +593,8 @@ export default { * @param status - The status. * @return {Promise} Whether the status was set. */ - setTaskStatus(appName: string, taskId: string, status: number): Promise { - // TODO this function implementation cannot - // be tested right now since we're unable - // to create a big enough upload to be able to - // pause/resume/cancel it in time. - return guard(async () => { - const task = tasks[taskId]; - - // If the task doesn't exist, return false. - if (!task) { - return false; - } - - let result = false; - - switch (status) { - case 0: - result = await task.pause(); - break; - case 1: - result = await task.resume(); - break; - case 2: - result = await task.cancel(); - break; - } - - emitEvent('storage_event', { - data: uploadTaskSnapshotToObject(task.snapshot), - appName, - taskId, - }); - - return result; - }); + setTaskStatus(_appName: string, _taskId: string, _status: number): Promise { + // Uploads use firebase-js `uploadBytes` (no resumable `UploadTask` registered on this path). + return guard(async () => false); }, };