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