diff --git a/lib/helpers/toFormData.js b/lib/helpers/toFormData.js index 5a503cfe..014c5517 100644 --- a/lib/helpers/toFormData.js +++ b/lib/helpers/toFormData.js @@ -156,6 +156,11 @@ function toFormData(obj, formData, options) { function defaultVisitor(value, key, path) { let arr = value; + if (utils.isReactNative(formData) && utils.isReactNativeBlob(value)) { + formData.append(renderKey(path, key, dots), convertValue(value)); + return false; + } + if (value && !path && typeof value === 'object') { if (utils.endsWith(key, '{}')) { // eslint-disable-next-line no-param-reassign diff --git a/lib/utils.js b/lib/utils.js index 13bb0116..73c4f0d2 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -186,6 +186,31 @@ const isDate = kindOfTest('Date'); */ const isFile = kindOfTest('File'); +/** + * Determine if a value is a React Native Blob + * React Native "blob": an object with a `uri` attribute. Optionally, it can + * also have a `name` and `type` attribute to specify filename and content type + * + * @see https://github.com/facebook/react-native/blob/26684cf3adf4094eb6c405d345a75bf8c7c0bf88/Libraries/Network/FormData.js#L68-L71 + * + * @param {*} value The value to test + * + * @returns {boolean} True if value is a React Native Blob, otherwise false + */ +const isReactNativeBlob = (value) => { + return !!(value && typeof value.uri !== 'undefined'); +} + +/** + * Determine if environment is React Native + * ReactNative `FormData` has a non-standard `getParts()` method + * + * @param {*} formData The formData to test + * + * @returns {boolean} True if environment is React Native, otherwise false + */ +const isReactNative = (formData) => formData && typeof formData.getParts !== 'undefined'; + /** * Determine if a value is a Blob * @@ -850,6 +875,8 @@ export default { isUndefined, isDate, isFile, + isReactNativeBlob, + isReactNative, isBlob, isRegExp, isFunction, diff --git a/test/unit/helpers/toFormData.js b/test/unit/helpers/toFormData.js index c4416e71..902d25be 100644 --- a/test/unit/helpers/toFormData.js +++ b/test/unit/helpers/toFormData.js @@ -3,6 +3,19 @@ import toFormData from '../../../lib/helpers/toFormData.js'; import FormData from 'form-data'; describe('helpers::toFormData', function () { + function createRNFormDataSpy() { + const calls = []; + return { + calls, + append(key, value) { + calls.push([key, value]); + }, + getParts() { + return []; + } + }; + } + it('should convert a flat object to FormData', function () { const data = { foo: 'bar', @@ -50,4 +63,70 @@ describe('helpers::toFormData', function () { const formData = toFormData(data, new FormData()); assert.ok(formData instanceof FormData); }); + + it('should append root-level React Native blob without recursion', function () { + const formData = createRNFormDataSpy(); + + const blob = { + uri: 'file://test.png', + type: 'image/png', + name: 'test.png' + }; + + toFormData({ file: blob }, formData); + + assert.strictEqual(formData.calls.length, 1); + assert.strictEqual(formData.calls[0][0], 'file'); + assert.strictEqual(formData.calls[0][1], blob); + }); + + it('should append nested React Native blob without recursion', function () { + const formData = createRNFormDataSpy(); + + const blob = { + uri: 'file://nested.png', + type: 'image/png', + name: 'nested.png' + }; + + toFormData({ nested: { file: blob } }, formData); + + assert.strictEqual(formData.calls.length, 1); + assert.strictEqual(formData.calls[0][0], 'nested[file]'); + assert.strictEqual(formData.calls[0][1], blob); + }); + + it('should append deeply nested React Native blob without recursion', function () { + const formData = createRNFormDataSpy(); + + const blob = { + uri: 'file://deep.png', + name: 'deep.png' + }; + + toFormData({ a: { b: { c: blob } } }, formData); + + assert.strictEqual(formData.calls.length, 1); + assert.strictEqual(formData.calls[0][0], 'a[b][c]'); + assert.strictEqual(formData.calls[0][1], blob); + }); + + it('should NOT recurse into React Native blob properties', function () { + const formData = createRNFormDataSpy(); + + const blob = { + uri: 'file://nope.png', + type: 'image/png', + name: 'nope.png' + }; + + toFormData({ file: blob }, formData); + + const keys = formData.calls.map(call => call[0]); + + assert.deepStrictEqual(keys, ['file']); + assert.ok(!keys.some(k => k.includes('uri'))); + assert.ok(!keys.some(k => k.includes('type'))); + assert.ok(!keys.some(k => k.includes('name'))); + }); }); diff --git a/test/unit/utils/utils.js b/test/unit/utils/utils.js index 908f4cff..48aa7ea7 100644 --- a/test/unit/utils/utils.js +++ b/test/unit/utils/utils.js @@ -120,4 +120,57 @@ describe('utils', function () { assert.strictEqual(result, null); }); }); + + describe('utils::isReactNativeBlob', function () { + it('should return true for objects with uri property', function () { + assert.strictEqual(utils.isReactNativeBlob({ uri: 'file://path/to/file' }), true); + assert.strictEqual(utils.isReactNativeBlob({ uri: 'content://media/image' }), true); + }); + + it('should return true for React Native blob-like objects with optional name and type', function () { + assert.strictEqual(utils.isReactNativeBlob({ + uri: 'file://path/to/file', + name: 'image.png', + type: 'image/png' + }), true); + }); + + it('should return false for objects without uri property', function () { + assert.strictEqual(utils.isReactNativeBlob({ path: 'file://path' }), false); + assert.strictEqual(utils.isReactNativeBlob({ url: 'http://example.com' }), false); + assert.strictEqual(utils.isReactNativeBlob({}), false); + }); + + it('should return false for non-objects', function () { + assert.strictEqual(utils.isReactNativeBlob(null), false); + assert.strictEqual(utils.isReactNativeBlob(undefined), false); + assert.strictEqual(utils.isReactNativeBlob('string'), false); + assert.strictEqual(utils.isReactNativeBlob(123), false); + assert.strictEqual(utils.isReactNativeBlob(false), false); + }); + + it('should return true even if uri is empty string', function () { + assert.strictEqual(utils.isReactNativeBlob({ uri: '' }), true); + }); + }); + + describe('utils::isReactNative', function () { + it('should return true for FormData with getParts method', function () { + const mockReactNativeFormData = { + append: function() {}, + getParts: function() { return []; } + }; + assert.strictEqual(utils.isReactNative(mockReactNativeFormData), true); + }); + + it('should return false for standard FormData without getParts method', function () { + const standardFormData = new FormData(); + assert.strictEqual(utils.isReactNative(standardFormData), false); + }); + + it('should return false for objects without getParts method', function () { + assert.strictEqual(utils.isReactNative({ append: function() {} }), false); + assert.strictEqual(utils.isReactNative({}), false); + }); + }); });