From a0fec00566ce758a147717155716418147c9a30a Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 20 Sep 2026 19:53:22 +0100 Subject: [PATCH] fix(react-native): upload files through Expo SDK 57's fetch Expo SDK 57 installs expo/fetch as the global fetch. Its multipart encoder only accepts strings, Blobs, or objects exposing bytes(), so the legacy React Native { uri, name, type } part the SDK appended threw "Unsupported FormDataPart implementation" before any request was sent. Every upload part now carries both uri, which React Native's own fetch reads natively, and a lazy bytes() that Expo's fetch encodes from. Neither stack pays for the other's path. React Native's Blob cannot be built from binary data, so a Blob part was not an option. The React Native e2e suite now runs the shared upload cases through Expo 57's real FormData patch and body encoder, transpiled from the installed expo package at build time. --- templates/react-native/src/client.ts.twig | 13 ++++ templates/react-native/src/service.ts.twig | 25 +++++- .../src/services/template.ts.twig | 32 +++++--- tests/e2e/ReactNativeTest.php | 10 ++- tests/e2e/languages/react-native/browser.js | 25 ++++++ .../react-native/rollup.test.config.mjs | 2 +- .../react-native/shims/expo-file-system.js | 76 +++++++++++++------ .../react-native/shims/expo-runtime.js | 40 ++++++++++ 8 files changed, 187 insertions(+), 36 deletions(-) create mode 100644 tests/e2e/languages/react-native/shims/expo-runtime.js diff --git a/templates/react-native/src/client.ts.twig b/templates/react-native/src/client.ts.twig index fc5307ed63..bb9b7fcfaa 100644 --- a/templates/react-native/src/client.ts.twig +++ b/templates/react-native/src/client.ts.twig @@ -157,6 +157,19 @@ export type UploadProgress = { chunksUploaded: number; }; +/** + * A multipart file part that both React Native networking stacks understand. + * + * React Native's own fetch reads `uri` natively. Expo's fetch, the global + * fetch since Expo SDK 57, ignores `uri` and encodes the body from `bytes()`. + */ +export type FilePart = { + uri: string; + name: string; + type: string; + bytes: () => Promise; +}; + class {{spec.info.title | caseUcfirst}}Exception extends Error { code: number; response: string; diff --git a/templates/react-native/src/service.ts.twig b/templates/react-native/src/service.ts.twig index 5fa7a7a134..1f34f85899 100644 --- a/templates/react-native/src/service.ts.twig +++ b/templates/react-native/src/service.ts.twig @@ -1,5 +1,5 @@ import { Client } from './client'; -import type { Payload } from './client'; +import type { FilePart, Payload } from './client'; export class Service { static CHUNK_SIZE = 5 * 1024 * 1024; // 5MB @@ -24,4 +24,27 @@ export class Service { return output; } + + static filePart( + uri: string, + name: string, + type: string, + read: () => Promise, + ): FilePart { + return { + uri, + name, + type, + bytes: async () => Service.decodeBase64(await read()), + }; + } + + static decodeBase64(data: string): Uint8Array { + const binary = atob(data); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index++) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; + } } diff --git a/templates/react-native/src/services/template.ts.twig b/templates/react-native/src/services/template.ts.twig index 2e112b0dd6..3907ec271b 100644 --- a/templates/react-native/src/services/template.ts.twig +++ b/templates/react-native/src/services/template.ts.twig @@ -187,6 +187,16 @@ export class {{ service.name | caseUcfirst }} extends Service { const size = {{ parameter.name | caseCamel | escapeKeyword }}.size; if (size <= Service.CHUNK_SIZE) { + payload['{{ parameter.name }}'] = Service.filePart( + {{ parameter.name | caseCamel | escapeKeyword }}.uri, + {{ parameter.name | caseCamel | escapeKeyword }}.name, + {{ parameter.name | caseCamel | escapeKeyword }}.type, + () => + FileSystem.readAsStringAsync({{ parameter.name | caseCamel | escapeKeyword }}.uri, { + encoding: FileSystem.EncodingType.Base64, + }), + ); + return this.client.call( '{{ method.method.value | caseLower }}', uri, @@ -267,11 +277,12 @@ export class {{ service.name | caseUcfirst }} extends Service { }); } - payload['{{ parameter.name }}'] = {{ '{' }} - uri: firstPath, - name: {{ parameter.name | caseCamel | escapeKeyword }}.name, - type: {{ parameter.name | caseCamel | escapeKeyword }}.type, - }; + payload['{{ parameter.name }}'] = Service.filePart( + firstPath, + {{ parameter.name | caseCamel | escapeKeyword }}.name, + {{ parameter.name | caseCamel | escapeKeyword }}.type, + async () => firstChunk, + ); response = await this.client.call( '{{ method.method.value | caseLower }}', @@ -350,11 +361,12 @@ export class {{ service.name | caseUcfirst }} extends Service { } const chunkPayload = { ...payload }; - chunkPayload['{{ parameter.name }}'] = {{ '{' }} - uri: chunkPath, - name: {{ parameter.name | caseCamel | escapeKeyword }}.name, - type: {{ parameter.name | caseCamel | escapeKeyword }}.type, - }; + chunkPayload['{{ parameter.name }}'] = Service.filePart( + chunkPath, + {{ parameter.name | caseCamel | escapeKeyword }}.name, + {{ parameter.name | caseCamel | escapeKeyword }}.type, + async () => chunkData, + ); const chunkResponse = await this.client.call( '{{ method.method.value | caseLower }}', diff --git a/tests/e2e/ReactNativeTest.php b/tests/e2e/ReactNativeTest.php index de55ec0e6b..aba724e1f2 100644 --- a/tests/e2e/ReactNativeTest.php +++ b/tests/e2e/ReactNativeTest.php @@ -29,8 +29,10 @@ final class ReactNativeTest extends Base 'cp tests/e2e/languages/react-native/index.html tests/e2e/sdks/react-native/index.html', 'cp tests/e2e/languages/react-native/browser.js tests/e2e/sdks/react-native/browser.js', 'cp tests/e2e/languages/react-native/rollup.test.config.mjs tests/e2e/sdks/react-native/rollup.test.config.mjs', - 'mkdir -p tests/e2e/sdks/react-native/shims && cp tests/e2e/languages/react-native/shims/expo-file-system.js tests/e2e/sdks/react-native/shims/expo-file-system.js', - 'docker run --rm -v $(pwd):/app -w /app/tests/e2e/sdks/react-native mcr.microsoft.com/playwright:v1.59.0-jammy sh -c "npm install && npm install --no-save react-native-web react react-dom @rollup/plugin-alias @rollup/plugin-commonjs @rollup/plugin-node-resolve @rollup/plugin-replace"', + 'mkdir -p tests/e2e/sdks/react-native/shims && cp tests/e2e/languages/react-native/shims/*.js tests/e2e/sdks/react-native/shims/', + 'cp tests/resources/file.png tests/resources/large_file.mp4 tests/e2e/sdks/react-native/', + 'docker run --rm -v $(pwd):/app -w /app/tests/e2e/sdks/react-native mcr.microsoft.com/playwright:v1.59.0-jammy sh -c "npm install && npm install --no-save esbuild expo@57 react-native-web react react-dom @rollup/plugin-alias @rollup/plugin-commonjs @rollup/plugin-node-resolve @rollup/plugin-replace"', + 'docker run --rm -v $(pwd):/app -w /app/tests/e2e/sdks/react-native mcr.microsoft.com/playwright:v1.59.0-jammy sh -c "npx esbuild node_modules/expo/src/winter/FormData.ts node_modules/expo/src/winter/fetch/RequestUtils.ts --bundle --format=esm --outbase=node_modules/expo/src --outdir=shims/expo"', 'docker run --rm -v $(pwd):/app -w /app/tests/e2e/sdks/react-native mcr.microsoft.com/playwright:v1.59.0-jammy sh -c "npx rollup -c rollup.test.config.mjs"', ]; @@ -48,6 +50,10 @@ final class ReactNativeTest extends Base ...Base::GENERAL_RESPONSES, ...Base::PATH_VALIDATION_RESPONSES, ...Base::DOWNLOAD_RESPONSES, + ...Base::UPLOAD_RESPONSE, + ...Base::LARGE_FILE_RESPONSES, + ...Base::UPLOAD_RESPONSE, // Object params + ...Base::LARGE_FILE_RESPONSES, ...Base::ENUM_RESPONSES, ...Base::MODEL_RESPONSES, ...Base::OPTIONAL_PARAM_RESPONSES, diff --git a/tests/e2e/languages/react-native/browser.js b/tests/e2e/languages/react-native/browser.js index 22162aa277..f8f9808e24 100644 --- a/tests/e2e/languages/react-native/browser.js +++ b/tests/e2e/languages/react-native/browser.js @@ -1,3 +1,4 @@ +import './shims/expo-runtime'; import { Client, Foo, @@ -142,6 +143,30 @@ import { // Download console.log(new TextDecoder().decode(await general.download())); + // Upload + const smallFile = { + name: 'file.png', + type: 'image/png', + size: 38756, + uri: 'http://localhost:3000/file.png', + }; + const largeFile = { + name: 'large_file.mp4', + type: 'video/mp4', + size: 16310023, + uri: 'http://localhost:3000/large_file.mp4', + }; + response = await general.upload('string', 123, ['string in array'], smallFile); + console.log(response.result); + response = await general.upload('string', 123, ['string in array'], largeFile); + console.log(response.result); + + // Upload (Object params) + response = await general.upload({ x: 'string', y: 123, z: ['string in array'], file: smallFile }); + console.log(response.result); + response = await general.upload({ x: 'string', y: 123, z: ['string in array'], file: largeFile }); + console.log(response.result); + // Enum response = await general.enum(MockType.First); console.log(response.result); diff --git a/tests/e2e/languages/react-native/rollup.test.config.mjs b/tests/e2e/languages/react-native/rollup.test.config.mjs index cc2e90bad7..62d4e843ef 100644 --- a/tests/e2e/languages/react-native/rollup.test.config.mjs +++ b/tests/e2e/languages/react-native/rollup.test.config.mjs @@ -50,7 +50,7 @@ export default { outDir: 'dist', rootDir: '.', }, - include: ['src/**/*.ts', 'browser.js'], + include: ['src/**/*.ts', 'browser.js', 'shims/**/*.js'], exclude: ['node_modules/**', 'dist/**'], }), ], diff --git a/tests/e2e/languages/react-native/shims/expo-file-system.js b/tests/e2e/languages/react-native/shims/expo-file-system.js index 412db8d0a2..e1f39f9ca0 100644 --- a/tests/e2e/languages/react-native/shims/expo-file-system.js +++ b/tests/e2e/languages/react-native/shims/expo-file-system.js @@ -1,28 +1,60 @@ -const notImplemented = (name) => () => { - throw new Error( - `expo-file-system shim: ${name} was called, but uploads are not exercised by this test harness.` - ); +// Serves files over HTTP from the test server so the SDK's upload code can +// read them the way it reads a device file in Expo. +const written = new Map(); + +const bytesOf = async (uri) => { + if (written.has(uri)) { + return written.get(uri); + } + const response = await fetch(uri); + return new Uint8Array(await response.arrayBuffer()); +}; + +const toBase64 = (bytes) => { + let binary = ""; + for (let index = 0; index < bytes.length; index += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000)); + } + return btoa(binary); +}; + +export const EncodingType = { UTF8: "utf8", Base64: "base64" }; +export const documentDirectory = ""; +export const cacheDirectory = ""; + +export const getInfoAsync = async (uri) => { + const bytes = await bytesOf(uri); + return { exists: true, size: bytes.length, uri }; }; -export const EncodingType = { UTF8: 'utf8', Base64: 'base64' }; -export const documentDirectory = ''; -export const cacheDirectory = ''; +export const readAsStringAsync = async (uri, options = {}) => { + const bytes = await bytesOf(uri); + const position = options.position ?? 0; + const length = options.length ?? bytes.length - position; + const slice = bytes.subarray(position, position + length); + return options.encoding === EncodingType.Base64 + ? toBase64(slice) + : new TextDecoder().decode(slice); +}; -export const getInfoAsync = notImplemented('getInfoAsync'); -export const readAsStringAsync = notImplemented('readAsStringAsync'); -export const writeAsStringAsync = notImplemented('writeAsStringAsync'); -export const deleteAsync = notImplemented('deleteAsync'); -export const uploadAsync = notImplemented('uploadAsync'); -export const FileSystemUploadType = { BINARY_CONTENT: 0, MULTIPART: 1 }; +export const writeAsStringAsync = async (uri, data, options = {}) => { + const bytes = + options.encoding === EncodingType.Base64 + ? Uint8Array.from(atob(data), (character) => character.charCodeAt(0)) + : new TextEncoder().encode(data); + written.set(uri, bytes); +}; + +export const deleteAsync = async (uri) => { + written.delete(uri); +}; export default { - EncodingType, - documentDirectory, - cacheDirectory, - getInfoAsync, - readAsStringAsync, - writeAsStringAsync, - deleteAsync, - uploadAsync, - FileSystemUploadType, + EncodingType, + documentDirectory, + cacheDirectory, + getInfoAsync, + readAsStringAsync, + writeAsStringAsync, + deleteAsync, }; diff --git a/tests/e2e/languages/react-native/shims/expo-runtime.js b/tests/e2e/languages/react-native/shims/expo-runtime.js new file mode 100644 index 0000000000..480c5a4a6c --- /dev/null +++ b/tests/e2e/languages/react-native/shims/expo-runtime.js @@ -0,0 +1,40 @@ +// Reproduces the part of Expo's native runtime that uploads go through since +// Expo SDK 57 made expo/fetch the global fetch: React Native's FormData, Expo's +// FormData patch, and Expo's multipart body encoder. Only the transport is the +// browser's fetch, sending the bytes Expo would hand to its native request. +// +// Expo ships these as TypeScript sources only, so the test build transpiles +// them out of node_modules with esbuild before bundling. +import { installFormDataPatch } from "./expo/winter/FormData"; +import { + normalizeBodyInitAsync, + normalizeHeadersInit, + overrideHeaders, +} from "./expo/winter/fetch/RequestUtils"; + +class ReactNativeFormData { + constructor() { + this._parts = []; + } + + append(key, value) { + this._parts.push([key, value]); + } +} + +globalThis.FormData = ReactNativeFormData; +installFormDataPatch(globalThis.FormData); + +const browserFetch = globalThis.fetch.bind(globalThis); + +globalThis.fetch = async (input, init = {}) => { + if (!(init.body instanceof globalThis.FormData)) { + return browserFetch(input, init); + } + const { body, overriddenHeaders } = await normalizeBodyInitAsync(init.body); + const headers = overrideHeaders( + normalizeHeadersInit(init.headers), + overriddenHeaders ?? [], + ); + return browserFetch(input, { ...init, headers, body }); +};