Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/SDK/Language/ReactNative.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
6 changes: 4 additions & 2 deletions templates/react-native/README.md.twig
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}
Expand All @@ -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;
Expand Down
11 changes: 6 additions & 5 deletions templates/react-native/package-lock.json.twig

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions templates/react-native/package.json.twig
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
13 changes: 13 additions & 0 deletions templates/react-native/src/lib/file-system.ts.twig
Original file line number Diff line number Diff line change
@@ -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 */
}
45 changes: 32 additions & 13 deletions templates/react-native/src/services/template.ts.twig
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion templates/react-native/tsconfig.json.twig
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"lib": ["ESNext", "DOM"],
"listEmittedFiles": false,
"listFiles": false,
"moduleResolution": "node",
"moduleResolution": "bundler",
"noFallthroughCasesInSwitch": true,
"pretty": true,
"rootDir": "src",
Expand Down
4 changes: 4 additions & 0 deletions tests/e2e/ReactNativeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions tests/e2e/languages/react-native/browser.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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);

Expand Down
6 changes: 5 additions & 1 deletion tests/e2e/languages/react-native/rollup.test.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
23 changes: 23 additions & 0 deletions tests/e2e/languages/react-native/shims/expo-file-system-root.js
Original file line number Diff line number Diff line change
@@ -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,
};
2 changes: 1 addition & 1 deletion tests/e2e/languages/react-native/shims/expo-file-system.js
Original file line number Diff line number Diff line change
@@ -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)) {
Expand Down
23 changes: 23 additions & 0 deletions tests/e2e/languages/react-native/shims/metro-require.js
Original file line number Diff line number Diff line change
@@ -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);
};
35 changes: 35 additions & 0 deletions tests/generation/GenerationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,14 @@ final class GenerationTest extends TestCase
],
];

/** @var array<string, array<string, string>> */
private const array PEER_DEPENDENCIES = [
'react-native' => [
'expo' => '>=52.0.0',
'expo-file-system' => '>=18.0.0',
],
];

/** @var array<string, array<string, string>> generated tree per language and platform */
private static array $generated = [];

Expand Down Expand Up @@ -793,6 +801,33 @@ public function testPackageMetadataIsDeclared(string $name): void
}
}

/** @return iterable<string, array{string}> */
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 `&quot;` rather than `"`. Go doc comments are
Expand Down
Loading