Skip to content

fix(react-native): upload files through Expo SDK 57's fetch - #1921

Merged
ChiragAgg5k merged 1 commit into
mainfrom
fix/react-native-expo-fetch-upload
Sep 20, 2026
Merged

ChiragAgg5k merged 1 commit into
mainfrom
fix/react-native-expo-fetch-upload

Conversation

@ChiragAgg5k

Copy link
Copy Markdown
Member

What this fixes

File uploads from the React Native SDK fail on Expo SDK 57 with:

AppwriteException: Unsupported FormDataPart implementation

The error is raised on the device before any bytes leave it. It affects every upload the SDK performs, including storage.createFile, for both small files and chunked large files. Reported by a user who upgraded from Expo 54 to 57 and whose profile photo upload started failing with no other change.

Which versions are affected

Runtime Global fetch Status
Bare React Native, any version React Native networking Works before and after this PR
Expo SDK 52 to 56 React Native networking (expo/fetch was opt-in) Works before and after this PR
Expo SDK 57+ expo/fetch (WinterCG) installed as globalThis.fetch Broken before this PR, works after
Expo SDK 57+ with EXPO_PUBLIC_USE_RN_FETCH=1 React Native networking Works before and after this PR

Expo made the switch in packages/expo/src/winter/runtime.native.ts:

const useRnFetch =
  process.env.EXPO_PUBLIC_USE_RN_FETCH === '1' || process.env.EXPO_PUBLIC_USE_RN_FETCH === 'true';

if (!useRnFetch) {
  install('fetch', () => require('./fetch').fetch);
}

What the SDK was sending

React Native's FormData is not the web one. Its "file" part has always been a plain object with a uri, which React Native's native networking layer opens and streams itself:

formData.append('file', { uri: 'file:///…/photo.jpg', name: 'photo.jpg', type: 'image/jpeg' });

The SDK built exactly that object in three places in templates/react-native/src/services/template.ts.twig: the whole-file branch for files up to 5 MB, the first chunk of a large upload, and every subsequent chunk. For chunks it first read the slice as base64 with expo-file-system, then handed it back as a data: URI on iOS or as a temp file path on Android, always through the uri field.

                     before this PR

  createFile({ file: { uri, name, type, size } })
                      │
                      ▼
      ┌───────────────────────────────────┐
      │ template.ts.twig                  │
      │  payload.file = { uri, name, type }│
      └───────────────┬───────────────────┘
                      ▼
      ┌───────────────────────────────────┐
      │ client.ts.twig                    │
      │  formData.append('file', part)    │
      │  fetch(url, { body: formData })   │
      └───────────────┬───────────────────┘
                      ▼
        ┌─────────────┴─────────────┐
        ▼                           ▼
  React Native fetch          expo/fetch (Expo 57)
  reads part.uri natively     convertFormDataAsync():
  streams the file             string?  no
  ✅ 201 Created               Blob?    no
                               bytes()? no
                               ❌ throw 'Unsupported FormDataPart implementation'

Expo's encoder, packages/expo/src/winter/fetch/convertFormData.ts, builds the multipart body in JavaScript and has no idea what a uri is:

if (typeof entry === 'string') {
  results.push(entry);
} else if (entry instanceof Blob) {
  results.push(new Uint8Array(await blobToArrayBufferAsync(entry)));
} else if (typeof entry === 'object' && 'bytes' in entry) {
  results.push(await entry.bytes());
} else {
  throw new Error('Unsupported FormDataPart implementation');
}

The SDK's call() wraps every thrown error in AppwriteException, which is why users see it as an Appwrite error even though nothing reached the server.

Why not a Blob

The obvious fix is to append a Blob, which both stacks accept. It does not work on React Native. BlobManager.createFromParts in React Native 0.83 rejects binary input outright:

if (part instanceof ArrayBuffer || ArrayBuffer.isView(part)) {
  throw new Error("Creating blobs from 'ArrayBuffer' and 'ArrayBufferView' are not supported");
}

A React Native Blob can only be made from strings or other native-backed Blobs, so there is no way to turn a chunk we read from disk into one.

What the fix does

Every upload part now satisfies both encoders at once. It keeps uri for React Native's networking layer and adds a lazy bytes() that Expo's encoder calls. Each stack reads only its own field, so React Native's fetch never decodes base64 and Expo's fetch never touches the uri.

// templates/react-native/src/client.ts.twig
export type FilePart = {
    uri: string;
    name: string;
    type: string;
    bytes: () => Promise<Uint8Array>;
};

// templates/react-native/src/service.ts.twig
static filePart(uri, name, type, read: () => Promise<string>): FilePart {
    return { uri, name, type, bytes: async () => Service.decodeBase64(await read()) };
}

The three call sites pass a reader instead of eagerly reading. Chunks already hold their base64 slice, so they return it. The whole-file branch, which previously passed the caller's object straight through, reads on demand:

// whole file (≤ 5 MB)
payload['file'] = Service.filePart(file.uri, file.name, file.type, () =>
    FileSystem.readAsStringAsync(file.uri, { encoding: FileSystem.EncodingType.Base64 }),
);

// first chunk and every later chunk
payload['file'] = Service.filePart(firstPath, file.name, file.type, async () => firstChunk);
                     after this PR

      ┌────────────────────────────────────────────┐
      │ payload.file = Service.filePart(           │
      │     uri, name, type, read)                 │
      │   = { uri, name, type, bytes() }           │
      └───────────────────┬────────────────────────┘
                          ▼
            ┌─────────────┴─────────────┐
            ▼                           ▼
      React Native fetch          expo/fetch (Expo 57)
      reads part.uri              'bytes' in part → await part.bytes()
      ignores bytes()             ignores part.uri
      ✅ 201 Created              ✅ 201 Created

Expo's own header builder already reads name and type off any object part, so Content-Disposition: form-data; name="file"; filename="…" and the part's Content-Type come out the same on both stacks.

Verification against the live API

The generated SDK was run in Node against https://sgp.cloud.appwrite.io/v1 through Expo 57.0.24's real installFormDataPatch and normalizeBodyInitAsync, imported from the published expo package, with react-native and expo-file-system stubbed. Each run uploaded, downloaded, compared bytes, and deleted.

Stack File Before After
expo/fetch 404 B PNG Unsupported FormDataPart implementation byte-identical
expo/fetch 7.3 MB, 2 chunks Unsupported FormDataPart implementation byte-identical
React Native fetch, iOS and Android both byte-identical byte-identical

Regression test

The React Native e2e suite now exercises the shared Base::UPLOAD_RESPONSE and Base::LARGE_FILE_RESPONSES cases that every other language already asserts. The harness underneath is what makes them meaningful for this bug:

  • shims/expo-runtime.js installs a React Native style FormData, applies Expo's installFormDataPatch, and routes multipart bodies through Expo's normalizeBodyInitAsync before the browser's fetch sends the bytes. Expo publishes these as TypeScript sources only, so the build step transpiles them out of node_modules/expo with esbuild. The test tracks the real Expo package rather than a vendored copy.
  • shims/expo-file-system.js serves the fixtures over the test HTTP server so readAsStringAsync with position and length behaves like it does on a device.

On main the suite fails with the reporter's exact error. With this PR it passes.

-'POST:/v1/mock/tests/general/upload:passed'
+'TEST RUNNER ERROR: Unsupported FormDataPart implementation'

Workaround for users on current releases

Set EXPO_PUBLIC_USE_RN_FETCH=1 in .env and rebuild. This restores React Native's fetch as the global and the current SDK release works unchanged.

Out of scope

package.json still pins expo-file-system to 18.*.*, which is the Expo 52 line. Expo 57 ships expo-file-system@57 and moved readAsStringAsync to expo-file-system/legacy. That pin will also bite Expo 57 users at install time and should be widened in its own change.

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.
@greptile-apps

greptile-apps Bot commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

The PR appears safe to merge because there are no outstanding findings or new changes since the previous review.

Summary

This PR adapts generated React Native upload parts for Expo SDK 57 while retaining compatibility with React Native networking.

  • Adds lazy bytes() support alongside the existing file URI metadata.
  • Applies the compatible representation to whole-file and chunked uploads.
  • Extends the React Native end-to-end suite to cover small and chunked uploads through an Expo-oriented harness.

Reviews (2) · Last reviewed commit: "fix(react-native): upload files through ..."

Comment thread tests/e2e/ReactNativeTest.php
@ChiragAgg5k

Copy link
Copy Markdown
Member Author

@greptile review

@ChiragAgg5k
ChiragAgg5k merged commit e09a510 into main Sep 20, 2026
59 checks passed
@ChiragAgg5k
ChiragAgg5k deleted the fix/react-native-expo-fetch-upload branch September 20, 2026 19:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant