From d9de8360ed532beb846a7858a5952ce975202706 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Mon, 12 Oct 2026 10:36:21 -0500 Subject: [PATCH 1/2] fix: don't import axios into RN SDK --- .../installNativeMultipartAdapter.test.ts | 86 ++++++++++++++----- .../utils/installNativeMultipartAdapter.ts | 84 +++++++++--------- 2 files changed, 109 insertions(+), 61 deletions(-) diff --git a/package/src/utils/__tests__/installNativeMultipartAdapter.test.ts b/package/src/utils/__tests__/installNativeMultipartAdapter.test.ts index cbb14c3109..24602a4f40 100644 --- a/package/src/utils/__tests__/installNativeMultipartAdapter.test.ts +++ b/package/src/utils/__tests__/installNativeMultipartAdapter.test.ts @@ -1,11 +1,12 @@ import type { AxiosProgressEvent, AxiosRequestConfig } from 'axios'; import { getTestClient } from '../../mock-builders/mock'; -import { NativeHandlers, NativeMultipartUploadProgressConfig } from '../../native'; import { - installNativeMultipartAdapter, - wrapAxiosAdapterWithNativeMultipart, -} from '../installNativeMultipartAdapter'; + isNativeMultipartUploadAvailable, + NativeHandlers, + NativeMultipartUploadProgressConfig, +} from '../../native'; +import { installNativeMultipartAdapter } from '../installNativeMultipartAdapter'; type NativeMultipartTestAxiosConfig = AxiosRequestConfig & { uploadProgress?: (event: AxiosProgressEvent) => void; @@ -362,18 +363,9 @@ describe('installNativeMultipartAdapter', () => { it('installs only once per client', async () => { const client = getTestClient(); preserveRequestData(client); - const defaultAdapter = jest.fn().mockResolvedValue({ - config: {}, - data: 'default', - headers: {}, - status: 200, - statusText: 'OK', - }); - - client.axiosInstance.defaults.adapter = defaultAdapter; + const use = jest.spyOn(client.axiosInstance.interceptors.request, 'use'); installNativeMultipartAdapter(client); - const installedAdapter = client.axiosInstance.defaults.adapter; installNativeMultipartAdapter(client); const formData = { @@ -391,12 +383,21 @@ describe('installNativeMultipartAdapter', () => { await client.axiosInstance.post('/uploads/image', formData); - expect(client.axiosInstance.defaults.adapter).toBe(installedAdapter); - expect(defaultAdapter).not.toHaveBeenCalled(); - expect(NativeHandlers.multipartUpload).toHaveBeenCalled(); + expect(use).toHaveBeenCalledTimes(1); + expect(NativeHandlers.multipartUpload).toHaveBeenCalledTimes(1); }); - it('composes explicitly with a custom adapter', async () => { + it('leaves the default adapter for the client’s own axios to resolve', () => { + const client = getTestClient(); + const defaultAdapter = client.axiosInstance.defaults.adapter; + + installNativeMultipartAdapter(client); + + expect(client.axiosInstance.defaults.adapter).toBe(defaultAdapter); + expect(client.axiosInstance.defaults.adapter).toEqual(['xhr', 'http', 'fetch']); + }); + + it('keeps a custom default adapter for non-multipart requests', async () => { const client = getTestClient(); preserveRequestData(client); const customAdapter = jest.fn().mockResolvedValue({ @@ -407,10 +408,8 @@ describe('installNativeMultipartAdapter', () => { statusText: 'OK', }); - client.axiosInstance.defaults.adapter = wrapAxiosAdapterWithNativeMultipart( - client, - customAdapter, - ); + client.axiosInstance.defaults.adapter = customAdapter; + installNativeMultipartAdapter(client); const multipartFormData = { _parts: [ @@ -429,9 +428,52 @@ describe('installNativeMultipartAdapter', () => { expect(NativeHandlers.multipartUpload).toHaveBeenCalled(); expect(customAdapter).not.toHaveBeenCalled(); + expect(client.axiosInstance.defaults.adapter).toBe(customAdapter); await client.axiosInstance.post('/messages', { text: 'hello' }); expect(customAdapter).toHaveBeenCalled(); }); + + it('rejects when an interceptor replaces the file body after it was routed natively', async () => { + const client = getTestClient(); + preserveRequestData(client); + + // Registered before the install, so axios runs it after the install's interceptor. + client.axiosInstance.interceptors.request.use((config) => { + config.data = { text: 'no longer a file' }; + return config; + }); + installNativeMultipartAdapter(client); + + const formData = { + _parts: [ + [ + 'file', + { + name: 'test.jpg', + type: 'image/jpeg', + uri: 'file:///tmp/test.jpg', + }, + ], + ], + }; + + await expect(client.axiosInstance.post('/uploads/image', formData)).rejects.toThrow( + /replaced the file body/, + ); + expect(NativeHandlers.multipartUpload).not.toHaveBeenCalled(); + }); + + it('does not install when native multipart upload is unavailable', () => { + // Nothing registers a native uploader in the test setup. + NativeHandlers.multipartUpload = originalMultipartUpload; + expect(isNativeMultipartUploadAvailable()).toBe(false); + const client = getTestClient(); + const use = jest.spyOn(client.axiosInstance.interceptors.request, 'use'); + + installNativeMultipartAdapter(client); + + expect(use).not.toHaveBeenCalled(); + }); }); diff --git a/package/src/utils/installNativeMultipartAdapter.ts b/package/src/utils/installNativeMultipartAdapter.ts index 38a5913c36..8e3f448a44 100644 --- a/package/src/utils/installNativeMultipartAdapter.ts +++ b/package/src/utils/installNativeMultipartAdapter.ts @@ -1,4 +1,3 @@ -import axios from 'axios'; import type { AxiosAdapter, AxiosProgressEvent, InternalAxiosRequestConfig } from 'axios'; import type { StreamChat } from 'stream-chat'; @@ -24,11 +23,9 @@ type NativeMultipartAxiosRequestConfig = InternalAxiosRequestConfig & { uploadProgress?: (event: AxiosProgressEvent) => void; }; -type ResolvableAxiosAdapter = Parameters[0]; - const DEFAULT_COMPLETION_PROGRESS_CAP = 90; -const installedAdapters = new WeakSet(); +const installedClients = new WeakSet(); const getFormDataEntries = (data: unknown): [string, FormDataPartValue][] | null => { if (!data || typeof data !== 'object') { @@ -80,11 +77,8 @@ const getNativeProgressOptions = ( return Object.keys(nativeProgressOptions).length ? nativeProgressOptions : undefined; }; -const createNativeMultipartRequest = ( - client: StreamChat, - config: NativeMultipartAxiosRequestConfig, -): NativeMultipartUploadRequest | null => { - const entries = getFormDataEntries(config.data); +const getNativeMultipartParts = (data: unknown): NativeMultipartUploadRequest['parts'] | null => { + const entries = getFormDataEntries(data); if (!entries) { return null; @@ -120,6 +114,19 @@ const createNativeMultipartRequest = ( return null; } + return parts; +}; + +const createNativeMultipartRequest = ( + client: StreamChat, + config: NativeMultipartAxiosRequestConfig, +): NativeMultipartUploadRequest | null => { + const parts = getNativeMultipartParts(config.data); + + if (!parts) { + return null; + } + return { headers: normalizeHeaders(config.headers), method: (config.method || 'POST').toUpperCase(), @@ -250,53 +257,52 @@ const nativeMultipartAxiosAdapter = async ( }; }; -const resolveAxiosAdapter = (adapter: ResolvableAxiosAdapter): AxiosAdapter => - axios.getAdapter(adapter); - -const createNativeMultipartAwareAdapter = ( - client: StreamChat, - fallbackAdapter: ResolvableAxiosAdapter, -): AxiosAdapter => { - const resolvedFallbackAdapter = resolveAxiosAdapter(fallbackAdapter); - - return (config) => { +const createNativeMultipartAdapter = + (client: StreamChat): AxiosAdapter => + (config) => { + // Built from the config the adapter receives, so it reflects every request interceptor — + // including those registered before this one, which axios runs after it. const nativeMultipartRequest = createNativeMultipartRequest( client, config as NativeMultipartAxiosRequestConfig, ); if (!nativeMultipartRequest) { - return resolvedFallbackAdapter(config); + return Promise.reject( + new Error( + 'Native multipart upload: a request interceptor replaced the file body after it was routed to the native uploader.', + ), + ); } return nativeMultipartAxiosAdapter(nativeMultipartRequest, config); }; -}; - -export const wrapAxiosAdapterWithNativeMultipart = ( - client: StreamChat, - fallbackAdapter: ResolvableAxiosAdapter, -): AxiosAdapter => { - if (!isNativeMultipartUploadAvailable()) { - return resolveAxiosAdapter(fallbackAdapter); - } - - return createNativeMultipartAwareAdapter(client, fallbackAdapter); -}; +/** + * Routes file uploads through the native uploader by setting `adapter` on those requests only. + * + * The client's default adapter is deliberately left alone. Replacing it would mean resolving the + * fallback (`['xhr', 'http', 'fetch']`) with `axios.getAdapter`, i.e. importing axios here — and + * wherever that import resolves to a different copy than `stream-chat`'s, every request would fail + * with that copy's `AxiosError`, which `stream-chat` does not recognise. Untouched, every other + * request is resolved by `stream-chat`'s own axios. + */ export const installNativeMultipartAdapter = (client: StreamChat) => { if (!isNativeMultipartUploadAvailable()) { return; } - if (installedAdapters.has(client)) { + if (installedClients.has(client)) { return; } - const previousAdapter = client.axiosInstance.defaults.adapter; - client.axiosInstance.defaults.adapter = wrapAxiosAdapterWithNativeMultipart( - client, - previousAdapter, - ); - installedAdapters.add(client); + const nativeMultipartAdapter = createNativeMultipartAdapter(client); + client.axiosInstance.interceptors.request.use((config) => { + if (getNativeMultipartParts(config.data)) { + config.adapter = nativeMultipartAdapter; + } + + return config; + }); + installedClients.add(client); }; From e5739cfc00869f024ec51470050bc82f707640df Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Wed, 14 Oct 2026 14:54:17 -0500 Subject: [PATCH 2/2] fix: review fixes --- .../installNativeMultipartAdapter.test.ts | 65 ++++++++++++++++++- .../utils/installNativeMultipartAdapter.ts | 7 +- 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/package/src/utils/__tests__/installNativeMultipartAdapter.test.ts b/package/src/utils/__tests__/installNativeMultipartAdapter.test.ts index 24602a4f40..61154ca863 100644 --- a/package/src/utils/__tests__/installNativeMultipartAdapter.test.ts +++ b/package/src/utils/__tests__/installNativeMultipartAdapter.test.ts @@ -394,7 +394,6 @@ describe('installNativeMultipartAdapter', () => { installNativeMultipartAdapter(client); expect(client.axiosInstance.defaults.adapter).toBe(defaultAdapter); - expect(client.axiosInstance.defaults.adapter).toEqual(['xhr', 'http', 'fetch']); }); it('keeps a custom default adapter for non-multipart requests', async () => { @@ -460,11 +459,73 @@ describe('installNativeMultipartAdapter', () => { }; await expect(client.axiosInstance.post('/uploads/image', formData)).rejects.toThrow( - /replaced the file body/, + /request body changed/, ); expect(NativeHandlers.multipartUpload).not.toHaveBeenCalled(); }); + it('routes a FormData upload natively through axios’ default request transform', async () => { + // Every other test swaps `transformRequest` for a no-op. This one keeps axios' default, which + // runs between the install's interceptor (sees the body before it) and the native adapter (sees + // it after), so the body has to pass through it untouched. axios captures the global FormData + // when it loads, so it is loaded fresh with React Native's FormData installed globally — as it + // is in an app — instead of Node's. + const originalFormData = global.FormData; + const RNFormData = jest.requireActual('react-native/Libraries/Network/FormData').default; + global.FormData = RNFormData; + + try { + let isolated!: { + getTestClient: typeof getTestClient; + installNativeMultipartAdapter: typeof installNativeMultipartAdapter; + NativeHandlers: typeof NativeHandlers; + }; + jest.isolateModules(() => { + isolated = { + getTestClient: require('../../mock-builders/mock').getTestClient, + installNativeMultipartAdapter: require('../installNativeMultipartAdapter') + .installNativeMultipartAdapter, + NativeHandlers: require('../../native').NativeHandlers, + }; + }); + isolated.NativeHandlers.multipartUpload = NativeHandlers.multipartUpload; + + const client = isolated.getTestClient(); + const defaultAdapter = jest.fn(); + client.axiosInstance.defaults.adapter = defaultAdapter; + isolated.installNativeMultipartAdapter(client); + + const formData = new RNFormData(); + formData.append('file', { + name: 'test.jpg', + type: 'image/jpeg', + uri: 'file:///tmp/test.jpg', + }); + formData.append('user', JSON.stringify({ id: 'john' })); + + const response = await client.axiosInstance.postForm('/uploads/image', formData); + + expect(defaultAdapter).not.toHaveBeenCalled(); + expect(NativeHandlers.multipartUpload).toHaveBeenCalledWith( + expect.objectContaining({ + parts: [ + { + fieldName: 'file', + fileName: 'test.jpg', + kind: 'file', + mimeType: 'image/jpeg', + uri: 'file:///tmp/test.jpg', + }, + { fieldName: 'user', kind: 'text', value: JSON.stringify({ id: 'john' }) }, + ], + }), + ); + expect(response.status).toBe(200); + } finally { + global.FormData = originalFormData; + } + }); + it('does not install when native multipart upload is unavailable', () => { // Nothing registers a native uploader in the test setup. NativeHandlers.multipartUpload = originalMultipartUpload; diff --git a/package/src/utils/installNativeMultipartAdapter.ts b/package/src/utils/installNativeMultipartAdapter.ts index 8e3f448a44..5f89d52041 100644 --- a/package/src/utils/installNativeMultipartAdapter.ts +++ b/package/src/utils/installNativeMultipartAdapter.ts @@ -260,8 +260,9 @@ const nativeMultipartAxiosAdapter = async ( const createNativeMultipartAdapter = (client: StreamChat): AxiosAdapter => (config) => { - // Built from the config the adapter receives, so it reflects every request interceptor — - // including those registered before this one, which axios runs after it. + // Built from the config the adapter receives, so it reflects everything that ran after routing: + // request interceptors registered before this one (axios runs them after it) and + // `transformRequest`. const nativeMultipartRequest = createNativeMultipartRequest( client, config as NativeMultipartAxiosRequestConfig, @@ -270,7 +271,7 @@ const createNativeMultipartAdapter = if (!nativeMultipartRequest) { return Promise.reject( new Error( - 'Native multipart upload: a request interceptor replaced the file body after it was routed to the native uploader.', + 'Native multipart upload: the request body changed after it was routed to the native uploader.', ), ); }