From f451ae01900d4a2b9d1c8622efcdb2800f146d6b Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sat, 17 Oct 2026 11:21:02 +0100 Subject: [PATCH] fix(react-native): let the app provide expo-file-system As a regular dependency pinned to 18, expo-file-system nested a second native copy in apps on Expo SDK 54 and later, crashing Android at launch, and from 19 the root legacy methods throw, breaking file uploads. No single pinned version works across Expo SDKs, so the app now supplies it as a peer and the SDK loads the legacy API from "expo-file-system/legacy" when it exists, falling back to the package root on 18 (Expo SDK 52 and 53). Android upload chunks written to the cache directory are now deleted once their request completes. --- src/SDK/Language/ReactNative.php | 5 +++ templates/react-native/README.md.twig | 6 ++- templates/react-native/package-lock.json.twig | 11 ++--- templates/react-native/package.json.twig | 5 ++- .../react-native/src/lib/file-system.ts.twig | 13 ++++++ .../src/services/template.ts.twig | 45 +++++++++++++------ templates/react-native/tsconfig.json.twig | 2 +- tests/e2e/ReactNativeTest.php | 4 ++ tests/e2e/languages/react-native/browser.js | 13 ++++++ .../react-native/rollup.test.config.mjs | 6 ++- .../shims/expo-file-system-root.js | 23 ++++++++++ .../react-native/shims/expo-file-system.js | 2 +- .../react-native/shims/metro-require.js | 23 ++++++++++ tests/generation/GenerationTest.php | 35 +++++++++++++++ 14 files changed, 168 insertions(+), 25 deletions(-) create mode 100644 templates/react-native/src/lib/file-system.ts.twig create mode 100644 tests/e2e/languages/react-native/shims/expo-file-system-root.js create mode 100644 tests/e2e/languages/react-native/shims/metro-require.js diff --git a/src/SDK/Language/ReactNative.php b/src/SDK/Language/ReactNative.php index a1300e12e3..20b0a05197 100644 --- a/src/SDK/Language/ReactNative.php +++ b/src/SDK/Language/ReactNative.php @@ -162,6 +162,11 @@ public function getFiles(): array 'destination' => 'src/lib/tcp-stream.ts', 'template' => 'react-native/src/lib/tcp-stream.ts.twig', ], + [ + 'scope' => 'default', + 'destination' => 'src/lib/file-system.ts', + 'template' => 'react-native/src/lib/file-system.ts.twig', + ], [ 'scope' => 'default', 'destination' => 'src/lib/polyfills.ts', diff --git a/templates/react-native/README.md.twig b/templates/react-native/README.md.twig index 5d44d6f0c8..a4c73a54f1 100644 --- a/templates/react-native/README.md.twig +++ b/templates/react-native/README.md.twig @@ -22,9 +22,11 @@ ## Installation ```bash -npx expo install {{ language.params.npmPackage }} react-native-url-polyfill +npx expo install {{ language.params.npmPackage }} expo-file-system react-native-url-polyfill ``` +The SDK supports Expo SDK 52 and later. It reads files for uploads through `expo-file-system`, which your app provides: `npx expo install` picks the version that matches your Expo SDK. + The SDK uses Expo modules. In a React Native project created without Expo, run `npx install-expo-modules@latest` first. {% if sdk.gettingStarted %} @@ -49,7 +51,7 @@ import {{ '{' }} Push {{ '}' }} from '{{ language.params.npmPackage }}'; const push = new Push(client); ``` -On Expo SDK 52 and older, enable package exports in `metro.config.js` so `mqtt` resolves to its React Native build (Expo SDK 53 and later do this by default): +On Expo SDK 52, enable package exports in `metro.config.js` so `mqtt` resolves to its React Native build (Expo SDK 53 and later do this by default): ```js config.resolver.unstable_enablePackageExports = true; diff --git a/templates/react-native/package-lock.json.twig b/templates/react-native/package-lock.json.twig index 673b356915..4fa3d0b9ba 100644 --- a/templates/react-native/package-lock.json.twig +++ b/templates/react-native/package-lock.json.twig @@ -10,7 +10,6 @@ "license": "{{ sdk.license }}", "dependencies": { "buffer": "6.0.3", - "expo-file-system": "18.*.*", "json-bigint": "1.0.0", "mqtt": "5.15.2", "process": "0.11.10", @@ -23,6 +22,7 @@ "@types/node": "24.9.2", "@types/readable-stream": "4.0.24", "eslint": "10.10.0", + "expo-file-system": "58.0.9", "playwright": "1.59.0", "prettier": "3.9.6", "rollup": "4.63.2", @@ -35,7 +35,8 @@ "node": ">=18.0.0" }, "peerDependencies": { - "expo": "*", + "expo": ">=52.0.0", + "expo-file-system": ">=18.0.0", "expo-notifications": "*", "react-native": ">=0.76.7 <1.0.0", "react-native-background-actions": ">=4.0.0 <5.0.0", @@ -4861,9 +4862,9 @@ } }, "node_modules/expo-file-system": { - "version": "18.1.11", - "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-18.1.11.tgz", - "integrity": "sha512-HJw/m0nVOKeqeRjPjGdvm+zBi5/NxcdPf8M8P3G2JFvH5Z8vBWqVDic2O58jnT1OFEy0XXzoH9UqFu7cHg9DTQ==", + "version": "58.0.9", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-58.0.9.tgz", + "integrity": "sha512-JT0km3Wa+wDz/7Rli8ifreoMnmk68ulf/uaWzRA26xm/4K+jGTY+cDhnboRmpmXUCBkED6uqxWmNoXjaVU9VUw==", "license": "MIT", "peerDependencies": { "expo": "*", diff --git a/templates/react-native/package.json.twig b/templates/react-native/package.json.twig index 1d80ffcf15..6455b5658c 100644 --- a/templates/react-native/package.json.twig +++ b/templates/react-native/package.json.twig @@ -44,6 +44,7 @@ "devDependencies": { "@eslint/js": "10.0.1", "eslint": "10.10.0", + "expo-file-system": "58.0.9", "@rollup/plugin-typescript": "12.3.0", "@types/json-bigint": "1.0.4", "@types/node": "24.9.2", @@ -58,14 +59,14 @@ }, "dependencies": { "buffer": "6.0.3", - "expo-file-system": "18.*.*", "json-bigint": "1.0.0", "mqtt": "5.15.2", "process": "0.11.10", "readable-stream": "4.7.0" }, "peerDependencies": { - "expo": "*", + "expo": ">=52.0.0", + "expo-file-system": ">=18.0.0", "expo-notifications": "*", "react-native": ">=0.76.7 <1.0.0", "react-native-background-actions": ">=4.0.0 <5.0.0", diff --git a/templates/react-native/src/lib/file-system.ts.twig b/templates/react-native/src/lib/file-system.ts.twig new file mode 100644 index 0000000000..284e273aaa --- /dev/null +++ b/templates/react-native/src/lib/file-system.ts.twig @@ -0,0 +1,13 @@ +import type * as Legacy from 'expo-file-system/legacy'; + +export type FileSystem = typeof Legacy; + +export function fileSystem(): FileSystem { + /* eslint-disable @typescript-eslint/no-require-imports */ + try { + return require('expo-file-system/legacy'); + } catch { + return require('expo-file-system'); + } + /* eslint-enable @typescript-eslint/no-require-imports */ +} diff --git a/templates/react-native/src/services/template.ts.twig b/templates/react-native/src/services/template.ts.twig index 4eb092e602..08185d42c7 100644 --- a/templates/react-native/src/services/template.ts.twig +++ b/templates/react-native/src/services/template.ts.twig @@ -27,7 +27,7 @@ import type { Models } from '../models'; {% set typeImports = typeImports | merge(['Payload']) %} import type { {{ typeImports | join(', ') }} } from '../client'; {% if usesUpload %} -import * as FileSystem from 'expo-file-system'; +import { fileSystem } from '../lib/file-system'; {% endif %} {% if usesChunkedUpload %} import { Platform as RNPlatform } from 'react-native'; @@ -209,6 +209,8 @@ export class {{ service.name | caseUcfirst }} extends Service { return{{ 'this.client.call' | jsArgs(uploadArgs, 12, ';', 7) | raw }} } {% endif %} + + const FileSystem = fileSystem(); {% if (method | textResponse) %} apiPayload['{{ parameter.name }}'] = Service.filePart( @@ -310,12 +312,20 @@ export class {{ service.name | caseUcfirst }} extends Service { async () => firstChunk, ); - response = await this.client.call( - '{{ method.method.value | caseLower }}', - uri, - firstChunkHeaders, - apiPayload, - ); + try { + response = await this.client.call( + '{{ method.method.value | caseLower }}', + uri, + firstChunkHeaders, + apiPayload, + ); + } finally { + if (RNPlatform.OS.toLowerCase() === 'android') { + await FileSystem.deleteAsync(firstPath, { + idempotent: true, + }).catch(() => undefined); + } + } offset = firstChunkEnd; if (onProgress) { @@ -405,12 +415,21 @@ export class {{ service.name | caseUcfirst }} extends Service { async () => chunkData, ); - const chunkResponse = await this.client.call( - '{{ method.method.value | caseLower }}', - uri, - chunkHeaders, - chunkPayload, - ); + let chunkResponse; + try { + chunkResponse = await this.client.call( + '{{ method.method.value | caseLower }}', + uri, + chunkHeaders, + chunkPayload, + ); + } finally { + if (RNPlatform.OS.toLowerCase() === 'android') { + await FileSystem.deleteAsync(chunkPath, { + idempotent: true, + }).catch(() => undefined); + } + } if (failed) { return chunkResponse; diff --git a/templates/react-native/tsconfig.json.twig b/templates/react-native/tsconfig.json.twig index c7292aa856..0bc3af2b26 100644 --- a/templates/react-native/tsconfig.json.twig +++ b/templates/react-native/tsconfig.json.twig @@ -9,7 +9,7 @@ "lib": ["ESNext", "DOM"], "listEmittedFiles": false, "listFiles": false, - "moduleResolution": "node", + "moduleResolution": "bundler", "noFallthroughCasesInSwitch": true, "pretty": true, "rootDir": "src", diff --git a/tests/e2e/ReactNativeTest.php b/tests/e2e/ReactNativeTest.php index c894f0798d..803c0399c1 100644 --- a/tests/e2e/ReactNativeTest.php +++ b/tests/e2e/ReactNativeTest.php @@ -58,6 +58,10 @@ final class ReactNativeTest extends Base ...Base::LARGE_FILE_RESPONSES, ...Base::UPLOAD_RESPONSE, // Object params ...Base::LARGE_FILE_RESPONSES, + ...Base::LARGE_FILE_RESPONSES, + 'expo-file-system 58 chunk files left: 0', + ...Base::LARGE_FILE_RESPONSES, + 'expo-file-system 18 chunk files left: 0', ...Base::GENERIC_UPLOAD_RESPONSES, ...Base::ENUM_RESPONSES, ...Base::MODEL_RESPONSES, diff --git a/tests/e2e/languages/react-native/browser.js b/tests/e2e/languages/react-native/browser.js index 8d59b1f965..6a03e92f35 100644 --- a/tests/e2e/languages/react-native/browser.js +++ b/tests/e2e/languages/react-native/browser.js @@ -1,4 +1,7 @@ import './shims/expo-runtime'; +import { useExpoFileSystem } from './shims/metro-require'; +import { written } from './shims/expo-file-system'; +import { Platform } from 'react-native'; import { Client, Foo, @@ -203,6 +206,16 @@ import { response = await general.upload({ x: 'string', y: 123, z: ['string in array'], file: largeFile }); console.log(response.result); + Platform.OS = 'android'; + for (const major of [58, 18]) { + useExpoFileSystem(major); + response = await general.upload('string', 123, ['string in array'], largeFile); + console.log(response.result); + console.log(`expo-file-system ${major} chunk files left: ${written.size}`); + } + useExpoFileSystem(58); + Platform.OS = 'web'; + response = await general.uploadGeneric({ file: smallFile }); console.log(response.prefs.result); diff --git a/tests/e2e/languages/react-native/rollup.test.config.mjs b/tests/e2e/languages/react-native/rollup.test.config.mjs index 62d4e843ef..6ce7d31156 100644 --- a/tests/e2e/languages/react-native/rollup.test.config.mjs +++ b/tests/e2e/languages/react-native/rollup.test.config.mjs @@ -22,9 +22,13 @@ export default { entries: [ { find: 'react-native', replacement: 'react-native-web' }, { - find: 'expo-file-system', + find: /^expo-file-system\/legacy$/, replacement: path.resolve(__dirname, 'shims/expo-file-system.js'), }, + { + find: /^expo-file-system$/, + replacement: path.resolve(__dirname, 'shims/expo-file-system-root.js'), + }, ], }), replace({ diff --git a/tests/e2e/languages/react-native/shims/expo-file-system-root.js b/tests/e2e/languages/react-native/shims/expo-file-system-root.js new file mode 100644 index 0000000000..3ecea5b28e --- /dev/null +++ b/tests/e2e/languages/react-native/shims/expo-file-system-root.js @@ -0,0 +1,23 @@ +import { EncodingType } from "./expo-file-system.js"; + +const removed = (name) => async () => { + throw new Error( + `Method ${name} imported from "expo-file-system" is deprecated.`, + ); +}; + +export { EncodingType }; +export const cacheDirectory = ""; +export const getInfoAsync = removed("getInfoAsync"); +export const readAsStringAsync = removed("readAsStringAsync"); +export const writeAsStringAsync = removed("writeAsStringAsync"); +export const deleteAsync = removed("deleteAsync"); + +export default { + EncodingType, + cacheDirectory, + getInfoAsync, + readAsStringAsync, + writeAsStringAsync, + deleteAsync, +}; 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 e1f39f9ca0..00bf95a19c 100644 --- a/tests/e2e/languages/react-native/shims/expo-file-system.js +++ b/tests/e2e/languages/react-native/shims/expo-file-system.js @@ -1,6 +1,6 @@ // 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(); +export const written = new Map(); const bytesOf = async (uri) => { if (written.has(uri)) { diff --git a/tests/e2e/languages/react-native/shims/metro-require.js b/tests/e2e/languages/react-native/shims/metro-require.js new file mode 100644 index 0000000000..e53817915e --- /dev/null +++ b/tests/e2e/languages/react-native/shims/metro-require.js @@ -0,0 +1,23 @@ +import * as legacy from "./expo-file-system.js"; +import * as root from "./expo-file-system-root.js"; + +const modules = new Map(); + +export const useExpoFileSystem = (major) => { + modules.clear(); + if (major >= 19) { + modules.set("expo-file-system/legacy", legacy); + modules.set("expo-file-system", root); + } else { + modules.set("expo-file-system", legacy); + } +}; + +useExpoFileSystem(58); + +globalThis.require = (name) => { + if (!modules.has(name)) { + throw new Error(`Requiring unknown module "${name}".`); + } + return modules.get(name); +}; diff --git a/tests/generation/GenerationTest.php b/tests/generation/GenerationTest.php index f1f7ce5882..c6bb1d9724 100644 --- a/tests/generation/GenerationTest.php +++ b/tests/generation/GenerationTest.php @@ -158,6 +158,14 @@ final class GenerationTest extends TestCase ], ]; + /** @var array> */ + private const array PEER_DEPENDENCIES = [ + 'react-native' => [ + 'expo' => '>=52.0.0', + 'expo-file-system' => '>=18.0.0', + ], + ]; + /** @var array> generated tree per language and platform */ private static array $generated = []; @@ -793,6 +801,33 @@ public function testPackageMetadataIsDeclared(string $name): void } } + /** @return iterable */ + public static function peerLanguages(): iterable + { + foreach (\array_keys(self::PEER_DEPENDENCIES) as $name) { + yield $name => [$name]; + } + } + + #[DataProvider('peerLanguages')] + public function testNativeModulesArePeerDependencies(string $name): void + { + $files = $this->generate($name, 'client'); + + foreach (['package.json' => null, 'package-lock.json' => ''] as $path => $root) { + $this->assertArrayHasKey($path, $files, "{$name} did not generate {$path}"); + $package = \json_decode($files[$path], true, flags: JSON_THROW_ON_ERROR); + if ($root !== null) { + $package = $package['packages'][$root]; + } + + foreach (self::PEER_DEPENDENCIES[$name] as $dependency => $range) { + $this->assertSame($range, $package['peerdependencies'][$dependency] ?? null, "{$name}: {$path} does not declare {$dependency} as a {$range} peer"); + $this->assertArrayNotHasKey($dependency, $package['dependencies'] ?? [], "{$name}: {$path} bundles {$dependency} as a dependency"); + } + } + } + /** * Twig autoescapes to HTML, so a description that reaches a template through * an unsafe filter arrives as `"` rather than `"`. Go doc comments are