diff --git a/apps/bare-expo/ios/BareExpo.xcodeproj/project.pbxproj b/apps/bare-expo/ios/BareExpo.xcodeproj/project.pbxproj
index 007714a6238278..78123c06d7ae08 100644
--- a/apps/bare-expo/ios/BareExpo.xcodeproj/project.pbxproj
+++ b/apps/bare-expo/ios/BareExpo.xcodeproj/project.pbxproj
@@ -264,7 +264,7 @@
);
mainGroup = 83CBB9F61A601CBA00E9B192;
packageReferences = (
- B07E3E252FA5EE03002CB39D /* XCLocalSwiftPackageReference "../../../packages/expo-modules-jsi/apple" */,
+ B07E3E252FA5EE03002CB39D /* XCLocalSwiftPackageReference "apple" */,
);
productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */;
projectDirPath = "";
@@ -907,7 +907,7 @@
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
- B07E3E252FA5EE03002CB39D /* XCLocalSwiftPackageReference "../../../packages/expo-modules-jsi/apple" */ = {
+ B07E3E252FA5EE03002CB39D /* XCLocalSwiftPackageReference "apple" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = "../../../packages/expo-modules-jsi/apple";
};
diff --git a/docs/.yarn/install-state.gz b/docs/.yarn/install-state.gz
new file mode 100644
index 00000000000000..16294a95277521
Binary files /dev/null and b/docs/.yarn/install-state.gz differ
diff --git a/packages/expo-doctor/CHANGELOG.md b/packages/expo-doctor/CHANGELOG.md
index 0e13cfeb4d3a54..24e792ae157e78 100644
--- a/packages/expo-doctor/CHANGELOG.md
+++ b/packages/expo-doctor/CHANGELOG.md
@@ -8,6 +8,7 @@
- Add version to the `--verbose` output ([#44592](https://github.com/expo/expo/pull/44592) by [@kitten](https://github.com/kitten))
- Add check that warns about invalid `overrides`/`resolutions` for critical package versions ([#44770](https://github.com/expo/expo/pull/44770) by [@kitten](https://github.com/kitten))
+- add a warning when mixing `@expo/vector-icons` and `react-native-vector-icons` or packages from `@react-native-vector-icons` ([#37958](https://github.com/expo/expo/pull/37958) by [@vonovak](https://github.com/vonovak))
### π Bug fixes
diff --git a/packages/expo-doctor/src/__tests__/doctor.test.ts b/packages/expo-doctor/src/__tests__/doctor.test.ts
index d3b97fcdad1918..793cd350783eb5 100644
--- a/packages/expo-doctor/src/__tests__/doctor.test.ts
+++ b/packages/expo-doctor/src/__tests__/doctor.test.ts
@@ -1,4 +1,5 @@
import { InstalledDependencyVersionCheck } from '../checks/InstalledDependencyVersionCheck';
+import { VectorIconsCheck } from '../checks/VectorIconsCheck';
import type { DoctorCheck } from '../checks/checks.types';
import {
printCheckResultSummaryOnComplete,
@@ -89,6 +90,32 @@ describe(resolveChecksInScope, () => {
checks.find((check) => check instanceof InstalledDependencyVersionCheck)
).not.toBeUndefined();
});
+
+ describe('VectorIconsCheck SDK version filtering', () => {
+ it('includes VectorIconsCheck for SDK 56 and above', async () => {
+ const checks = resolveChecksInScope(
+ {
+ name: 'foo',
+ slug: 'foo',
+ sdkVersion: '56.0.0',
+ },
+ {}
+ );
+ expect(checks.find((check) => check instanceof VectorIconsCheck)).not.toBeUndefined();
+ });
+
+ it('excludes VectorIconsCheck for SDK 55 and below', async () => {
+ const checks = resolveChecksInScope(
+ {
+ name: 'foo',
+ slug: 'foo',
+ sdkVersion: '55.0.0',
+ },
+ {}
+ );
+ expect(checks.find((check) => check instanceof VectorIconsCheck)).toBeUndefined();
+ });
+ });
});
describe(runChecksAsync, () => {
diff --git a/packages/expo-doctor/src/checks/VectorIconsCheck.ts b/packages/expo-doctor/src/checks/VectorIconsCheck.ts
new file mode 100644
index 00000000000000..9dc19debc211ee
--- /dev/null
+++ b/packages/expo-doctor/src/checks/VectorIconsCheck.ts
@@ -0,0 +1,36 @@
+import { DoctorCheck, DoctorCheckParams, DoctorCheckResult } from './checks.types';
+import { getDeepDependenciesWarningAsync } from '../utils/explainDependencies';
+
+export class VectorIconsCheck implements DoctorCheck {
+ description =
+ 'Check that @expo/vector-icons package is not installed together with other potentially conflicting icon packages.';
+
+ sdkVersionRange = '>=56.0.0';
+
+ async runAsync({ projectRoot }: DoctorCheckParams): Promise {
+ // Check what icon packages are installed
+ const [reactNativeVectorIconsCommon, expoVectorIcons, reactNativeVectorIcons] =
+ await Promise.all([
+ getDeepDependenciesWarningAsync({ name: '@react-native-vector-icons/common' }, projectRoot),
+ getDeepDependenciesWarningAsync({ name: '@expo/vector-icons' }, projectRoot),
+ getDeepDependenciesWarningAsync({ name: 'react-native-vector-icons' }, projectRoot),
+ ]);
+
+ const issues: string[] = [];
+ if (reactNativeVectorIconsCommon && (expoVectorIcons || reactNativeVectorIcons)) {
+ issues.push(
+ 'This project or its dependencies uses both the [scoped icon packages](https://www.npmjs.com/org/react-native-vector-icons) and [`@expo/vector-icons`](https://www.npmjs.com/package/@expo/vector-icons) or deprecated [`react-native-vector-icons`](https://www.npmjs.com/package/react-native-vector-icons) packages. This can lead to icon rendering issues due to conflicts between the packages.'
+ );
+ }
+
+ return {
+ isSuccessful: !issues.length,
+ issues,
+ advice: issues.length
+ ? [
+ 'If you wish to use the scoped icon packages (recommended), migrate your project by running the codemod: `npx @react-native-vector-icons/codemod`',
+ ]
+ : [],
+ };
+ }
+}
diff --git a/packages/expo-doctor/src/checks/__tests__/VectorIconsCheck.test.ts b/packages/expo-doctor/src/checks/__tests__/VectorIconsCheck.test.ts
new file mode 100644
index 00000000000000..97a7c767d686bf
--- /dev/null
+++ b/packages/expo-doctor/src/checks/__tests__/VectorIconsCheck.test.ts
@@ -0,0 +1,208 @@
+import { explainAsync } from '../../utils/explainAsync';
+import { RootNodePackage } from '../../utils/explainDependencies.types';
+import { VectorIconsCheck } from '../VectorIconsCheck';
+
+jest.mock('../../utils/explainAsync');
+
+// required by runAsync
+const additionalProjectProps = {
+ exp: {
+ name: 'name',
+ slug: 'slug',
+ sdkVersion: '56.0.0',
+ },
+ pkg: {},
+ hasUnusedStaticConfig: false,
+ staticConfigPath: null,
+ dynamicConfigPath: null,
+};
+
+describe('VectorIconsCheck', () => {
+ it('returns result with isSuccessful = true if @expo/vector-icons is not installed', async () => {
+ jest.mocked(explainAsync).mockResolvedValue(null);
+
+ const check = new VectorIconsCheck();
+ const result = await check.runAsync({
+ projectRoot: '/path/to/project',
+ ...additionalProjectProps,
+ });
+ expect(result.isSuccessful).toBeTruthy();
+ expect(result.issues).toHaveLength(0);
+ expect(result.advice).toHaveLength(0);
+ });
+
+ it('returns result with isSuccessful = false if @expo/vector-icons and also @react-native-vector-icons/common is installed', async () => {
+ jest.mocked(explainAsync).mockImplementation(async (packageName: string) => {
+ if (packageName === '@react-native-vector-icons/common') {
+ return expoProjectWithNewVectorIconsFixture;
+ }
+ if (packageName === '@expo/vector-icons') {
+ return expoGoProjectWithExpoIconsSimplifiedFixture;
+ }
+ return null;
+ });
+
+ const check = new VectorIconsCheck();
+ const result = await check.runAsync({
+ projectRoot: '/path/to/project',
+ ...additionalProjectProps,
+ });
+ expect(result.isSuccessful).toBeFalsy();
+ expect(result.issues).toHaveLength(1);
+ expect(result.issues).toHaveLength(1);
+ });
+});
+
+const expoProjectWithNewVectorIconsFixture: RootNodePackage[] = [
+ {
+ name: '@react-native-vector-icons/common',
+ version: '12.0.1',
+ location: 'node_modules/@react-native-vector-icons/common',
+ isWorkspace: false,
+ dependents: [
+ {
+ type: 'prod',
+ name: '@react-native-vector-icons/common',
+ spec: '^12.0.1',
+ from: {
+ name: '@react-native-vector-icons/evil-icons',
+ version: '12.0.1',
+ location: 'node_modules/@react-native-vector-icons/evil-icons',
+ isWorkspace: false,
+ dependents: [
+ {
+ type: 'prod',
+ name: '@react-native-vector-icons/evil-icons',
+ spec: '^12.0.1',
+ from: {
+ name: '@react-native-vector-icons/common',
+ version: '12.0.1',
+ location: 'some-location',
+ },
+ },
+ ],
+ },
+ },
+ ],
+ dev: false,
+ optional: false,
+ devOptional: false,
+ peer: false,
+ bundled: false,
+ },
+];
+
+const expoGoProjectWithExpoIconsSimplifiedFixture: RootNodePackage[] = [
+ {
+ name: '@expo/vector-icons',
+ version: '14.1.0',
+ location: 'node_modules/@expo/vector-icons',
+ isWorkspace: false,
+ dependents: [
+ {
+ type: 'prod',
+ name: '@expo/vector-icons',
+ spec: '^14.0.0',
+ from: {
+ name: 'expo',
+ version: '54.0.18',
+ location: 'node_modules/expo',
+ isWorkspace: false,
+ dependents: [
+ {
+ type: 'prod',
+ name: 'expo',
+ spec: '~54.0.17',
+ from: {
+ name: 'test-project',
+ version: '1.0.0',
+ location: '/Users/vojta/_dev/repros/icons-expo-go',
+ isWorkspace: false,
+ dependents: [],
+ },
+ },
+ {
+ type: 'peer',
+ name: 'expo',
+ spec: '*',
+ from: {
+ name: 'expo-asset',
+ version: '11.1.7',
+ location: 'node_modules/expo-asset',
+ isWorkspace: false,
+ dependents: [
+ {
+ type: 'prod',
+ name: 'expo-asset',
+ spec: '~11.1.7',
+ from: {
+ name: 'expo',
+ version: '54.0.18',
+ location: 'node_modules/expo',
+ isWorkspace: false,
+ dependents: [],
+ },
+ },
+ ],
+ },
+ },
+ {
+ type: 'peer',
+ name: 'expo',
+ spec: '*',
+ from: {
+ name: 'expo-font',
+ version: '13.3.2',
+ location: 'node_modules/expo-font',
+ isWorkspace: false,
+ dependents: [
+ {
+ type: 'prod',
+ name: 'expo-font',
+ spec: '~13.3.2',
+ from: {
+ name: 'test-project',
+ version: '1.0.0',
+ location: '/Users/vojta/_dev/repros/icons-expo-go',
+ isWorkspace: false,
+ dependents: [],
+ },
+ },
+ {
+ type: 'prod',
+ name: 'expo-font',
+ spec: '~13.3.2',
+ from: {
+ name: 'expo',
+ version: '54.0.18',
+ location: 'node_modules/expo',
+ isWorkspace: false,
+ dependents: [],
+ },
+ },
+ {
+ type: 'peer',
+ name: 'expo-font',
+ spec: '*',
+ from: {
+ name: '@expo/vector-icons',
+ version: '14.1.0',
+ location: 'node_modules/@expo/vector-icons',
+ isWorkspace: false,
+ dependents: [],
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ ],
+ dev: false,
+ optional: false,
+ devOptional: false,
+ peer: false,
+ bundled: false,
+ },
+];
diff --git a/packages/expo-doctor/src/utils/checkResolver.ts b/packages/expo-doctor/src/utils/checkResolver.ts
index 42264e0ff31890..f9afddfbc12c6b 100644
--- a/packages/expo-doctor/src/utils/checkResolver.ts
+++ b/packages/expo-doctor/src/utils/checkResolver.ts
@@ -26,6 +26,7 @@ import { ProjectSetupCheck } from '../checks/ProjectSetupCheck';
import { ReactNativeDirectoryCheck } from '../checks/ReactNativeDirectoryCheck';
import { StoreCompatibilityCheck } from '../checks/StoreCompatibilityCheck';
import { SupportPackageVersionCheck } from '../checks/SupportPackageVersionCheck';
+import { VectorIconsCheck } from '../checks/VectorIconsCheck';
import type { DoctorCheck } from '../checks/checks.types';
/**
@@ -52,6 +53,7 @@ export function resolveChecksInScope(exp: ExpoConfig, pkg: PackageJSONConfig): D
new DirectPackageInstallCheck(),
new PeerDependencyChecks(),
new AutolinkingDependencyDuplicatesCheck(),
+ new VectorIconsCheck(),
// Version Checks
new SupportPackageVersionCheck(),
diff --git a/packages/expo-doctor/src/utils/explainAsync.ts b/packages/expo-doctor/src/utils/explainAsync.ts
new file mode 100644
index 00000000000000..d05152007bdc77
--- /dev/null
+++ b/packages/expo-doctor/src/utils/explainAsync.ts
@@ -0,0 +1,43 @@
+import spawnAsync, { SpawnResult } from '@expo/spawn-async';
+import chalk from 'chalk';
+
+import { RootNodePackage } from './explainDependencies.types';
+import { Log } from './log';
+
+function isSpawnResult(result: any): result is SpawnResult {
+ return 'stderr' in result && 'stdout' in result && 'status' in result;
+}
+
+/** Spawn `npm explain [name] --json` and return the parsed JSON. Returns `null` if the requested package is not installed. */
+export async function explainAsync(
+ packageName: string,
+ projectRoot: string,
+ parameters: string[] = []
+): Promise {
+ const args = ['explain', packageName, ...parameters, '--json'];
+
+ try {
+ const { stdout } = await spawnAsync('npm', args, {
+ stdio: 'pipe',
+ cwd: projectRoot,
+ });
+
+ return JSON.parse(stdout);
+ } catch (error: any) {
+ if (isSpawnResult(error)) {
+ if (error.stderr.match(/No dependencies found matching/)) {
+ return null;
+ } else if (error.stdout.match(/Usage: npm /)) {
+ throw new Error(
+ `Dependency tree validation for ${chalk.underline(
+ packageName
+ )} failed. This validation is only available on Node 16+ / npm 8.`
+ );
+ }
+ }
+ if (error.stderr) {
+ Log.debug(error.stderr);
+ }
+ throw new Error(`Failed to find dependency tree for ${packageName}: ` + error.message);
+ }
+}
diff --git a/packages/expo-doctor/src/utils/explainDependencies.ts b/packages/expo-doctor/src/utils/explainDependencies.ts
index 987c41ad3d0ce7..59d148e0fa4389 100644
--- a/packages/expo-doctor/src/utils/explainDependencies.ts
+++ b/packages/expo-doctor/src/utils/explainDependencies.ts
@@ -2,54 +2,14 @@
// adapted to return warnings instead of displaying, with some modest renaming to match,
// but otherwise logic is unchanged.
-import type { SpawnResult } from '@expo/spawn-async';
-import spawnAsync from '@expo/spawn-async';
import chalk from 'chalk';
import semver from 'semver';
+import { explainAsync } from './explainAsync';
import type { RootNodePackage, VersionSpec } from './explainDependencies.types';
-import { Log } from './log';
type TargetPackage = { name: string; version?: VersionSpec };
-function isSpawnResult(result: any): result is SpawnResult {
- return 'stderr' in result && 'stdout' in result && 'status' in result;
-}
-
-/** Spawn `npm explain [name] --json` and return the parsed JSON. Returns `null` if the requested package is not installed. */
-async function explainAsync(
- packageName: string,
- projectRoot: string,
- parameters: string[] = []
-): Promise {
- const args = ['explain', packageName, ...parameters, '--json'];
-
- try {
- const { stdout } = await spawnAsync('npm', args, {
- stdio: 'pipe',
- cwd: projectRoot,
- });
-
- return JSON.parse(stdout);
- } catch (error: any) {
- if (isSpawnResult(error)) {
- if (error.stderr.match(/No dependencies found matching/)) {
- return null;
- } else if (error.stdout.match(/Usage: npm /)) {
- throw new Error(
- `Dependency tree validation for ${chalk.underline(
- packageName
- )} failed. This validation is only available on Node 16+ / npm 8.`
- );
- }
- }
- if (error.stderr) {
- Log.debug(error.stderr);
- }
- throw new Error(`Failed to find dependency tree for ${packageName}: ` + error.message);
- }
-}
-
function organizeExplanations(
pkg: TargetPackage,
{
@@ -125,6 +85,7 @@ function formatPkg(pkg: TargetPackage, versionColor: string) {
/**
* @param pkg
+ * @param projectRoot
* @returns string if there's a warning, null if otherwise
*/
export async function getDeepDependenciesWarningAsync(
diff --git a/packages/expo-modules-core/ios/Core/DynamicTypes/AnyDynamicType.swift b/packages/expo-modules-core/ios/Core/DynamicTypes/AnyDynamicType.swift
index 90e8cc82618f8c..837c8dada79e59 100644
--- a/packages/expo-modules-core/ios/Core/DynamicTypes/AnyDynamicType.swift
+++ b/packages/expo-modules-core/ios/Core/DynamicTypes/AnyDynamicType.swift
@@ -19,14 +19,17 @@ public protocol AnyDynamicType: CustomStringConvertible, Sendable {
func equals(_ type: AnyDynamicType) -> Bool
/**
- Preliminarily casts the given JavaScriptValue to a non-JS value that the other `cast` function can handle.
+ Casts the given JavaScript value to the final wrapped native value, ready to be
+ handed to the function's underlying closure or stored in a record/prop.
It **must** be run on the thread used by the JavaScript runtime.
*/
@JavaScriptActor
func cast(jsValue: JavaScriptValue, appContext: AppContext) throws -> Any
/**
- Casts the given value to the wrapped type and returns it as `Any`.
+ Casts the given Swift-side value to the wrapped type and returns it as `Any`.
+ Used by record-field setters, view props, and other non-JS-origin paths that
+ need to coerce a Swift value into the dynamic type's representation.
NOTE: It may not be just simple type-casting (e.g. when the wrapped type conforms to `Convertible`).
*/
func cast(_ value: ValueType, appContext: AppContext) throws -> Any
@@ -49,10 +52,6 @@ public protocol AnyDynamicType: CustomStringConvertible, Sendable {
}
extension AnyDynamicType {
- func cast(jsValue: JavaScriptValue, appContext: AppContext) throws -> Any {
- return jsValue.getAny()
- }
-
func cast(_ value: ValueType, appContext: AppContext) throws -> Any {
return value
}
diff --git a/packages/expo-modules-core/ios/Core/DynamicTypes/DynamicConvertibleType.swift b/packages/expo-modules-core/ios/Core/DynamicTypes/DynamicConvertibleType.swift
index 4b106b9bcc9371..6db896dc4ebc8a 100644
--- a/packages/expo-modules-core/ios/Core/DynamicTypes/DynamicConvertibleType.swift
+++ b/packages/expo-modules-core/ios/Core/DynamicTypes/DynamicConvertibleType.swift
@@ -26,7 +26,7 @@ internal struct DynamicConvertibleType: AnyDynamicType {
try record.update(withObject: try jsValue.asObject(), appContext: appContext)
return record
}
- return jsValue.getAny()
+ return try innerType.convert(from: jsValue.getAny(), appContext: appContext)
}
func cast(_ value: ValueType, appContext: AppContext) throws -> Any {
diff --git a/packages/expo-modules-core/ios/Core/DynamicTypes/DynamicEitherType.swift b/packages/expo-modules-core/ios/Core/DynamicTypes/DynamicEitherType.swift
index d17ee4672f4e0c..c70fad15e74cd8 100644
--- a/packages/expo-modules-core/ios/Core/DynamicTypes/DynamicEitherType.swift
+++ b/packages/expo-modules-core/ios/Core/DynamicTypes/DynamicEitherType.swift
@@ -19,8 +19,7 @@ internal struct DynamicEitherType: AnyDynamicType {
let types = eitherType.dynamicTypes()
for type in types {
- if let preliminaryValue = try? type.cast(jsValue: jsValue, appContext: appContext),
- let value = try? type.cast(preliminaryValue, appContext: appContext) {
+ if let value = try? type.cast(jsValue: jsValue, appContext: appContext) {
return EitherType(value)
}
}
diff --git a/packages/expo-modules-core/ios/Core/DynamicTypes/DynamicEncodableType.swift b/packages/expo-modules-core/ios/Core/DynamicTypes/DynamicEncodableType.swift
index e5f395da7f7e15..e94b63d62a99bf 100644
--- a/packages/expo-modules-core/ios/Core/DynamicTypes/DynamicEncodableType.swift
+++ b/packages/expo-modules-core/ios/Core/DynamicTypes/DynamicEncodableType.swift
@@ -18,6 +18,11 @@ internal struct DynamicEncodableType: AnyDynamicType {
return false
}
+ func cast(jsValue: JavaScriptValue, appContext: AppContext) throws -> Any {
+ // TODO: Create DynamicDecodableType and reuse it here β that would work perfectly with Codable types
+ fatalError("DynamicEncodableType can only cast to JavaScript, not from")
+ }
+
func cast(_ value: ValueType, appContext: AppContext) throws -> Any {
// TODO: Create DynamicDecodableType and reuse it here β that would work perfectly with Codable types
fatalError("DynamicEncodableType can only cast to JavaScript, not from")
diff --git a/packages/expo-modules-core/ios/Core/DynamicTypes/DynamicEnumType.swift b/packages/expo-modules-core/ios/Core/DynamicTypes/DynamicEnumType.swift
index b552c1c56d9491..6ae0d0184ae619 100644
--- a/packages/expo-modules-core/ios/Core/DynamicTypes/DynamicEnumType.swift
+++ b/packages/expo-modules-core/ios/Core/DynamicTypes/DynamicEnumType.swift
@@ -25,8 +25,9 @@ internal struct DynamicEnumType: AnyDynamicType {
}
func cast(_ value: ValueType, appContext: AppContext) throws -> Any {
- // Idempotent: `MainValueConverter.toNative` calls this after `cast(jsValue:)`,
- // which already hydrates the enum. Pass it through unchanged in that case.
+ // Pass through values that are already the hydrated enum, e.g. when called from
+ // record-field setters with a `[String: Any]` dictionary that already contains the
+ // enum case (rather than its raw value).
if let value = value as? any Enumerable, type(of: value) == innerType {
return value
}
diff --git a/packages/expo-modules-core/ios/Core/DynamicTypes/DynamicRawType.swift b/packages/expo-modules-core/ios/Core/DynamicTypes/DynamicRawType.swift
index d4049d1b0a634b..3a00445ade1327 100644
--- a/packages/expo-modules-core/ios/Core/DynamicTypes/DynamicRawType.swift
+++ b/packages/expo-modules-core/ios/Core/DynamicTypes/DynamicRawType.swift
@@ -17,6 +17,10 @@ internal struct DynamicRawType: AnyDynamicType {
return type is Self
}
+ func cast(jsValue: JavaScriptValue, appContext: AppContext) throws -> Any {
+ return try cast(jsValue.getAny(), appContext: appContext)
+ }
+
func cast(_ value: ValueType, appContext: AppContext) throws -> Any {
if let value = value as? InnerType {
return value
diff --git a/packages/expo-modules-core/ios/Core/DynamicTypes/DynamicSwiftUIViewType.swift b/packages/expo-modules-core/ios/Core/DynamicTypes/DynamicSwiftUIViewType.swift
index e7af9aec2fd679..92abbccb0f56fc 100644
--- a/packages/expo-modules-core/ios/Core/DynamicTypes/DynamicSwiftUIViewType.swift
+++ b/packages/expo-modules-core/ios/Core/DynamicTypes/DynamicSwiftUIViewType.swift
@@ -17,13 +17,13 @@ internal struct DynamicSwiftUIViewType: AnyDynamicTyp
}
/**
- Casts from the React component instance to the view tag (`Int`).
+ Resolves the React component instance to the native SwiftUI view via its tag.
*/
func cast(jsValue: JavaScriptValue, appContext: AppContext) throws -> Any {
guard let viewTag = findViewTag(jsValue) else {
throw InvalidViewTagException()
}
- return viewTag
+ return try cast(viewTag, appContext: appContext)
}
/**
diff --git a/packages/expo-modules-core/ios/Core/DynamicTypes/DynamicValueOrUndefinedType.swift b/packages/expo-modules-core/ios/Core/DynamicTypes/DynamicValueOrUndefinedType.swift
index f6561495916759..98a5b311e97fbd 100644
--- a/packages/expo-modules-core/ios/Core/DynamicTypes/DynamicValueOrUndefinedType.swift
+++ b/packages/expo-modules-core/ios/Core/DynamicTypes/DynamicValueOrUndefinedType.swift
@@ -18,8 +18,8 @@ internal struct DynamicValueOrUndefinedType: AnyDynamicT
if jsValue.isUndefined() {
return ValueOrUndefined.undefined
}
-
- return try dynamicInnerType.cast(jsValue: jsValue, appContext: appContext)
+ let unwrapped = try dynamicInnerType.cast(jsValue: jsValue, appContext: appContext) as! InnerType
+ return ValueOrUndefined.value(unwrapped: unwrapped)
}
func cast(_ value: ValueType, appContext: AppContext) throws -> Any {
diff --git a/packages/expo-modules-core/ios/Core/DynamicTypes/DynamicViewType.swift b/packages/expo-modules-core/ios/Core/DynamicTypes/DynamicViewType.swift
index 0c516b934d81c4..3035432d6ba1d3 100644
--- a/packages/expo-modules-core/ios/Core/DynamicTypes/DynamicViewType.swift
+++ b/packages/expo-modules-core/ios/Core/DynamicTypes/DynamicViewType.swift
@@ -17,13 +17,13 @@ internal struct DynamicViewType: AnyDynamicType {
}
/**
- Casts from the React component instance to the view tag (`Int`).
+ Resolves the React component instance to the native view via its tag.
*/
func cast(jsValue: JavaScriptValue, appContext: AppContext) throws -> Any {
guard let viewTag = findViewTag(jsValue) else {
throw InvalidViewTagException()
}
- return viewTag
+ return try cast(viewTag, appContext: appContext)
}
/**
diff --git a/packages/expo-modules-core/ios/Core/MainValueConverter.swift b/packages/expo-modules-core/ios/Core/MainValueConverter.swift
index 031e9337e58f1e..90e152e49c3847 100644
--- a/packages/expo-modules-core/ios/Core/MainValueConverter.swift
+++ b/packages/expo-modules-core/ios/Core/MainValueConverter.swift
@@ -15,8 +15,7 @@ public struct MainValueConverter: ~Copyable {
*/
@JavaScriptActor
public func toNative(_ value: JavaScriptValue, _ type: AnyDynamicType) throws -> Any {
- let rawValue = try type.cast(jsValue: value, appContext: appContext)
- return try type.cast(rawValue, appContext: appContext)
+ return try type.cast(jsValue: value, appContext: appContext)
}
/**
@@ -29,7 +28,7 @@ public struct MainValueConverter: ~Copyable {
let type = types[index]
do {
- return try toNative(value.copy(), type)
+ return try toNative(value, type)
} catch {
throw ArgumentCastException((index: index, type: type)).causedBy(error)
}
diff --git a/packages/expo-modules-core/ios/Core/Records/Field.swift b/packages/expo-modules-core/ios/Core/Records/Field.swift
index 79c1c66f3a61af..7458e2c5d4ed70 100644
--- a/packages/expo-modules-core/ios/Core/Records/Field.swift
+++ b/packages/expo-modules-core/ios/Core/Records/Field.swift
@@ -119,9 +119,7 @@ public final class Field: AnyFieldInternal, @unchecked Sendab
@JavaScriptActor
internal func castValue(jsValue: JavaScriptValue, appContext: AppContext) throws -> Type? {
- let rawValue = try fieldType.cast(jsValue: jsValue, appContext: appContext)
- let convertedValue = try fieldType.cast(rawValue, appContext: appContext)
- return convertedValue as? Type
+ return try fieldType.cast(jsValue: jsValue, appContext: appContext) as? Type
}
}
diff --git a/packages/expo-modules-jsi/CLAUDE.md b/packages/expo-modules-jsi/CLAUDE.md
index 029f0d66c95b3e..84b6a3b2806fc6 100644
--- a/packages/expo-modules-jsi/CLAUDE.md
+++ b/packages/expo-modules-jsi/CLAUDE.md
@@ -1,32 +1,8 @@
## Expo Modules JSI
-`expo-modules-jsi` provides type-safe Swift bindings to Facebook's JSI (JavaScript Interface) C++ library. It enables native Swift code to interact with JavaScript runtimes (Hermes) through a Swift-first API. Part of the Expo modules ecosystem.
+See [README.md](./README.md) for the public overview: what the package is, the layered architecture, the public API surface, Swift/C++ configuration, installation, and distribution. Don't duplicate that material here — update the README instead.
-## Architecture
-
-Three-layer design bridging JSI C++ to Swift:
-
-1. **Swift Layer** (`apple/Sources/ExpoModulesJSI/`) β Public API. Type-safe wrappers around JSI concepts: `JavaScriptRuntime`, `JavaScriptValue`, `JavaScriptObject`, `JavaScriptFunction`, `JavaScriptPromise`, etc. All JS value types are **non-copyable** (`~Copyable`) and conform to `JavaScriptType`. Uses `JavaScriptRef` to convert to reference semantics when needed (escaping closures, containers).
-
-2. **C++ Utilities Layer** (`apple/Sources/ExpoModulesJSI-Cxx/`) β Internal C++ helpers that bridge Swift and JSI. Headers in `include/`: `JSIUtils.h`, `HostObject.h`, `HostFunctionClosure.h`, `CppError.h`, `RuntimeScheduler.h`, `TypedArray.h`.
-
-3. **JSI / Hermes** β Binary xcframeworks (`React`, `hermes-engine`, `ReactNativeDependencies`) consumed as SPM binary targets.
-
-### Key Design Patterns
-
-- **`@JavaScriptActor`** β Global actor enforcing JS thread safety at compile time. Uses a synchronous executor (no thread hopping). Code must be scheduled onto the JS thread externally via `runtime.schedule()` or `runtime.execute()`.
-- **Non-copyable value types** β All JS wrappers (`JavaScriptValue`, `JavaScriptObject`, etc.) are `~Copyable` to match JSI's ownership semantics. Use `JavaScriptRef` when reference semantics are needed.
-- **C++ interop** β Swift/C++ interoperability is enabled via `.interoperabilityMode(.Cxx)` in Package.swift. APINotes (`apple/APINotes/jsi.apinotes`) control how JSI types are treated by the Swift compiler, e.g. as a value or reference type.
-- **`JavaScriptRepresentable`** β Protocol for converting Swift types to/from JS values. Has default implementations for primitives, String, Array, Dictionary, Optional.
-- **Error bridging** β `capturingCppErrors()` converts C++ exceptions to Swift errors. `CppError` provides thread-safe C++ exception storage.
-
-## Swift & C++ Configuration
-
-- **Swift 6.0** with strict concurrency (`-strict-concurrency=complete`)
-- **C++20** standard
-- **Platforms:** iOS 16.4+, tvOS 16.4+, macOS 13.4+
-- **Library evolution** enabled for binary framework distribution
-- Upcoming Swift features: `NonisolatedNonsendingByDefault`, `InferIsolatedConformances`
+This file holds context that's only useful when working *inside* the package.
## Directory Structure
@@ -35,23 +11,34 @@ apple/
βββ APINotes/jsi.apinotes # Controls how JSI C++ types appear in Swift
βββ Package.swift # SPM package definition
βββ ExpoModulesJSI.podspec # CocoaPods spec
-βββ build.sh # Builds .xcframework from SPM package
+βββ scripts/ # Build scripts (e.g. xcframework packaging)
+βββ Products/ # Build output (xcframeworks)
βββ Sources/
β βββ ExpoModulesJSI/ # Main Swift library
β β βββ Contexts/ # Bridging contexts for host functions/objects
β β βββ Extensions/ # Swift extensions (e.g. Task+immediate)
β β βββ Protocols/ # JavaScriptType, JavaScriptRepresentable, etc.
β β βββ Runtime/ # JavaScriptRuntime, JavaScriptActor, JavaScriptRef
-β β β βββ Values/ # JS value wrappers (Object, Array, Function, Promise, etc.)
+β β β βββ Values/ # JS value wrappers (Value, Object, Array, Function, ArrayBuffer, TypedArray, Promise, BigInt, Error, WeakObject)
β β βββ Utilities/ # Error handling, DeferredPromise, helpers
-β βββ ExpoModulesJSI-Cxx/ # C++ utilities bridging Swift β JSI
-β β βββ include/ # C++ headers (JSIUtils, HostObject, CppError, etc.)
-β β βββ TypedArray.cpp # Typed array implementation
-β βββ ExpoModulesJSI-RuntimeProvider/ # ObjC++ bridge for runtime provisioning
+β βββ ExpoModulesJSI-Cxx/ # C++ utilities bridging Swift β JSI
+β βββ include/ # C++ headers
+β βββ JSIUtils.cpp
+β βββ TypedArray.cpp
βββ Tests/ # Swift Testing suites, one per type
```
-Root-level files (`package.json`, `index.js`, `expo-module.config.json`, etc.) are npm package scaffolding β the actual implementation is entirely in `apple/`.
+C++ headers in `apple/Sources/ExpoModulesJSI-Cxx/include/`: `CppError.h`, `HostFunctionClosure.h`, `HostObject.h`, `HostObjectCallbacks.h`, `JSIUtils.h`, `MemoryBuffer.h`, `NativeState.h`, `RetainedSwiftPointer.h`, `RuntimeScheduler.h`, `TypedArray.h`.
+
+Root-level files (`package.json`, `index.js`, `expo-module.config.json`, etc.) are npm package scaffolding — the actual implementation is entirely in `apple/`. The npm package has no JS runtime code; `index.js` exports null.
+
+## Build
+
+See README.md for the rationale (Swift/C++ interop is contained inside this package, so consumers link a prebuilt xcframework instead of building from sources). Operational notes:
+
+- `apple/scripts/build-xcframework.sh` is the real build, invoked from the podspec's `script_phase` and run automatically as part of the host app's compilation. It shells out to SPM, hashes inputs to skip no-op rebuilds, and writes additive per-platform slices into `apple/Products/ExpoModulesJSI.xcframework`. Cache lives in `apple/.xcframework-slices/` and `.DerivedData` / `.build` next to the package.
+- `apple/scripts/create-stub-xcframework.sh` runs as the podspec's `prepare_command` to materialize an empty xcframework so CocoaPods inserts the copy/embed phases. The primary path for the stub is `ensure_expo_modules_jsi_stub_xcframework` in `expo-modules-autolinking`; `prepare_command` is a fallback because CocoaPods skips it on cache hits.
+- Run the script manually with `PODS_ROOT=/path/to/Pods apple/scripts/build-xcframework.sh [--clean]`, or `pnpm build` from the package root. `PLATFORM_NAME` narrows it to a single platform (e.g. `iphonesimulator`).
## Testing
@@ -72,8 +59,4 @@ struct JavaScriptRuntimeTests {
Tests are in `apple/Tests/` and each file covers one type. Some suites use the global actor `@JavaScriptActor` for executor isolation.
-## Distribution
-
-- **CocoaPods** via `apple/ExpoModulesJSI.podspec` β distributes as a static framework with vendored `ExpoModulesJSI.xcframework`
-- **SPM** via `apple/Package.swift`
-- The npm package (`expo-modules-jsi`) has no JS runtime code β `index.js` exports null
+Run them with `pnpm test` from the package root, which calls `apple/scripts/test.sh`. The script needs an installed host app's `Pods` directory (defaults to `$EXPO_ROOT_DIR/apps/bare-expo/ios/Pods`); override with `PODS_ROOT`. It symlinks React / hermesvm / ReactNativeDependencies xcframeworks into `apple/.test-frameworks/` so SPM can resolve them as relative-path binary targets, generates the `jsi` modulemap, and runs `xcodebuild test` against an iOS Simulator (override with `DESTINATION`). Extra args pass through to xcodebuild — e.g. `pnpm test -only-testing TestName`.
diff --git a/packages/expo-modules-jsi/README.md b/packages/expo-modules-jsi/README.md
index 7d3ef18a199fef..d186bd719f899d 100644
--- a/packages/expo-modules-jsi/README.md
+++ b/packages/expo-modules-jsi/README.md
@@ -7,4 +7,94 @@
-`expo-modules-jsi` provides type-safe Swift bindings to Facebook's JSI (JavaScript Interface) C++ library. It enables native Swift code to interact with JavaScript runtimes (Hermes) through a Swift-first API. Part of the Expo modules ecosystem.
+`expo-modules-jsi` provides type-safe Swift bindings to React Native's JSI (JavaScript Interface) C++ library. It lets native Swift code interact with the JavaScript runtime (Hermes) through a Swift-first API and is the foundation that newer parts of `expo-modules-core` build on.
+
+This package has no JavaScript runtime code — it is consumed natively on iOS via CocoaPods or Swift Package Manager. The npm package only exists so the native sources can be autolinked into your app.
+
+# Architecture
+
+Three-layer design bridging JSI C++ to Swift:
+
+1. **Swift Layer** (`apple/Sources/ExpoModulesJSI/`) — Public API. Type-safe wrappers around JSI concepts: `JavaScriptRuntime`, `JavaScriptValue`, `JavaScriptObject`, `JavaScriptFunction`, etc. All JS value types are non-copyable (`~Copyable`) and conform to `JavaScriptType`. Use `JavaScriptRef` to convert to reference semantics when needed (escaping closures, containers).
+2. **C++ Utilities Layer** (`apple/Sources/ExpoModulesJSI-Cxx/`) — Internal C++ helpers that bridge Swift and JSI.
+3. **JSI / Hermes** — Binary xcframeworks (`React`, `hermes-engine`, `ReactNativeDependencies`) consumed as SPM binary targets.
+
+# Public API
+
+- `JavaScriptRuntime` — entry point for evaluating scripts, scheduling work on the JS thread, and creating values.
+- `JavaScriptValue`, `JavaScriptObject`, `JavaScriptArray`, `JavaScriptFunction`, `JavaScriptArrayBuffer`, `JavaScriptTypedArray`, `JavaScriptPromise`, `JavaScriptBigInt`, `JavaScriptError`, `JavaScriptWeakObject` — non-copyable (`~Copyable`) wrappers around their JSI counterparts.
+- `JavaScriptRef` — turns any of the above into a reference type for use in escaping closures and containers.
+- `JavaScriptRepresentable` — protocol for converting Swift types to and from JS values, with default implementations for primitives, `String`, `Array`, `Dictionary`, and `Optional`.
+- `@JavaScriptActor` — global actor that enforces JS-thread isolation at compile time. The executor is synchronous (no thread hopping); code must be scheduled onto the JS thread externally via `runtime.schedule()` or `runtime.execute()`.
+- Error bridging — `capturingCppErrors()` converts C++ exceptions into Swift errors; `CppError` provides thread-safe C++ exception storage.
+
+C++ interoperability is enabled with `.interoperabilityMode(.Cxx)`, and `apple/APINotes/jsi.apinotes` controls how individual JSI types surface in Swift.
+
+# Swift & C++ Configuration
+
+- **Swift 6.0** with strict concurrency (`-strict-concurrency=complete`)
+- **C++20** standard
+- **Platforms:** iOS 16.4+, tvOS 16.4+, macOS 13.4+
+- **Library evolution** enabled for binary framework distribution
+- Upcoming Swift features: `NonisolatedNonsendingByDefault`, `InferIsolatedConformances`
+
+# Installation
+
+This package is not meant to be installed directly. It ships as a transitive native dependency of [`expo-modules-core`](../expo-modules-core), which is included in any Expo project. Adding it to your app's `package.json` is unnecessary and unsupported.
+
+# Distribution
+
+- **CocoaPods** via `apple/ExpoModulesJSI.podspec` — distributed as a static framework with a vendored `ExpoModulesJSI.xcframework`.
+- **Swift Package Manager** via `apple/Package.swift`.
+
+# Building
+
+The package can't be consumed from sources directly: it relies on Swift/C++ interop, which is a per-target compiler setting. Source distribution would force every Expo module that depends on it — and transitively the host app — to enable Swift/C++ interop too, which is invasive and significantly increases build times for each module. Instead, the sources are compiled into a binary `ExpoModulesJSI.xcframework` that consumers link against, so Swift/C++ interop stays contained inside this package.
+
+The build is wired up in `apple/ExpoModulesJSI.podspec`:
+
+- A `script_phase` runs `apple/scripts/build-xcframework.sh` before headers on every build of the host app. The script invokes SPM under the hood, applies hash-based caching to skip rebuilds when sources haven't changed, and produces additive per-platform slices in `apple/Products/ExpoModulesJSI.xcframework`.
+- A `prepare_command` runs `apple/scripts/create-stub-xcframework.sh` so CocoaPods generates the "Copy XCFrameworks" and "Embed Pods Frameworks" build phases even before the real xcframework exists.
+- The xcframework is declared as `vendored_frameworks`, so dependents see it as a regular binary dependency with no interop flags of their own.
+
+You can also build and test the package directly:
+
+```sh
+pnpm build # rebuild the xcframework outside of a Pods install
+pnpm test # run the Swift Testing suite on an iOS Simulator
+```
+
+`pnpm test` runs against an installed host app's `Pods` directory (defaults to `apps/bare-expo`); set `PODS_ROOT` to point at a different one. Extra arguments are forwarded to `xcodebuild` (e.g. `pnpm test -only-testing TestName`).
+
+# Using JSI types from a module
+
+Module authors don't import `ExpoModulesJSI` directly — `expo-modules-core` re-exports its types, so `import ExpoModulesCore` is enough. The Expo Modules API marshals JSI values automatically when you declare them in a `ModuleDefinition`:
+
+```swift
+import ExpoModulesCore
+
+public class MyModule: Module {
+ public func definition() -> ModuleDefinition {
+ Name("MyModule")
+
+ Function("printString") { (value: JavaScriptValue) in
+ print(value.getString())
+ }
+ }
+}
+```
+
+For lower-level access, reach the runtime through the module's `appContext`. Schedule work onto the JS thread to call into JSI safely:
+
+```swift
+AsyncFunction("evaluate") {
+ try appContext?.runtime.schedule(priority: .immediate) {
+ let result = try appContext?.runtime.eval("1 + 2")
+ print(result?.getInt() ?? 0)
+ }
+}
+```
+
+# Contributing
+
+Contributions are very welcome! Please refer to the guidelines described in the [contributing guide](https://github.com/expo/expo#contributing).
diff --git a/packages/expo-modules-jsi/apple/.gitignore b/packages/expo-modules-jsi/apple/.gitignore
index 1c26cabb813b07..0671944f2f3d45 100644
--- a/packages/expo-modules-jsi/apple/.gitignore
+++ b/packages/expo-modules-jsi/apple/.gitignore
@@ -9,5 +9,8 @@
# Staged slices for incremental xcframework builds
.xcframework-slices
+# Symlinks to host-app xcframeworks used by the test target (written by scripts/test.sh)
+.test-frameworks
+
# Built xcframework output
Products
diff --git a/packages/expo-modules-jsi/apple/Package.swift b/packages/expo-modules-jsi/apple/Package.swift
index d02f4722b157a0..951bed943a0632 100644
--- a/packages/expo-modules-jsi/apple/Package.swift
+++ b/packages/expo-modules-jsi/apple/Package.swift
@@ -4,11 +4,8 @@
import PackageDescription
import Foundation
-// Resolve PODS_ROOT from the environment. The build is driven from a CocoaPods
-// build phase that always sets PODS_ROOT before invoking xcodebuild. If it's
-// missing, fall back to a placeholder so `swift package describe` and IDE
-// indexing don't crash β actual compilation will fail loudly.
-let podsRoot = ProcessInfo.processInfo.environment["PODS_ROOT"] ?? "PODS_ROOT_NOT_SET"
+let packageDir = URL(fileURLWithPath: #filePath).deletingLastPathComponent().path
+let podsRoot = resolvePodsRoot()
// Header roots needed by ExpoModulesJSI and ExpoModulesJSI-Cxx. The same paths
// exist in both prebuilt and source-built React Native layouts, so we don't
@@ -38,17 +35,19 @@ let headerSearchPaths = [
"\(publicHeaders)/fast_float",
]
-// Path to the generated module map for the `jsi` Clang module. The build
-// script writes this file at build time so the absolute header path can be
-// resolved against the runtime PODS_ROOT. Lives outside `.build/` so the
-// build script can wipe SwiftPM state without losing the modulemap.
-let packageDir = URL(fileURLWithPath: #filePath).deletingLastPathComponent().path
+// Path to the generated module map for the `jsi` Clang module. The
+// `scripts/generate-modulemap.sh` script writes this file at build time so
+// the absolute header path can be resolved against the runtime PODS_ROOT.
+// Lives outside `.build/` so SwiftPM state can be wiped without losing this
+// file.
let generatedModuleMap = "\(packageDir)/.generated/module.modulemap"
let apiNotesPath = "\(packageDir)/APINotes"
let cxxIncludeFlags = headerSearchPaths.map({ "-I\($0)" })
let swiftIncludeFlags = headerSearchPaths.flatMap({ ["-Xcc", "-I\($0)"] })
+let testFrameworks = resolveTestFrameworks()
+
let package = Package(
name: "ExpoModulesJSI",
platforms: [
@@ -120,9 +119,47 @@ let package = Package(
// Tests
.testTarget(
name: "Tests",
- dependencies: ["ExpoModulesJSI"],
+ dependencies: testFrameworks.dependencies,
),
- ],
+ ] + testFrameworks.binaryTargets,
swiftLanguageModes: [.v6],
cxxLanguageStandard: .cxx20
)
+
+// Resolve PODS_ROOT from the environment. CocoaPods build phases always set
+// it; otherwise fall back to bare-expo's Pods so headers resolve for
+// indexing and direct script invocations. The manifest sandbox blocks
+// `fileExists` outside the package β fail loudly later if Pods aren't there.
+func resolvePodsRoot() -> String {
+ let env = ProcessInfo.processInfo.environment
+ if let explicit = env["PODS_ROOT"] {
+ return explicit
+ }
+ let repoRoot = env["EXPO_ROOT_DIR"] ?? URL(fileURLWithPath: packageDir)
+ .deletingLastPathComponent() // expo-modules-jsi
+ .deletingLastPathComponent() // packages
+ .deletingLastPathComponent() // repo root
+ .path
+ return "\(repoRoot)/apps/bare-expo/ios/Pods"
+}
+
+// Prebuilt xcframeworks the test bundle links against so JSI, Hermes, and
+// React symbols resolve at load time. The production xcframework build leaves
+// these unresolved (`-undefined dynamic_lookup`) because the host app provides
+// them β but a unit-test bundle has no such host.
+//
+// SwiftPM requires `binaryTarget` paths to be relative to the package root,
+// so the test wrapper script (`scripts/test.sh`) symlinks each xcframework
+// from $PODS_ROOT into `.test-frameworks/` before invoking xcodebuild.
+func resolveTestFrameworks() -> (binaryTargets: [Target], dependencies: [Target.Dependency]) {
+ let names = ["React", "hermesvm", "ReactNativeDependencies"]
+ let available = names.filter({
+ FileManager.default.fileExists(atPath: "\(packageDir)/.test-frameworks/\($0).xcframework")
+ })
+ let binaryTargets: [Target] = available.map({
+ .binaryTarget(name: $0, path: ".test-frameworks/\($0).xcframework")
+ })
+ let dependencies: [Target.Dependency] = ["ExpoModulesJSI"]
+ + available.map({ .target(name: $0) })
+ return (binaryTargets, dependencies)
+}
diff --git a/packages/expo-modules-jsi/apple/Sources/ExpoModulesJSI/Runtime/Values/JavaScriptArray.swift b/packages/expo-modules-jsi/apple/Sources/ExpoModulesJSI/Runtime/Values/JavaScriptArray.swift
index dbd8edc6f540a2..12acf78bcf8035 100644
--- a/packages/expo-modules-jsi/apple/Sources/ExpoModulesJSI/Runtime/Values/JavaScriptArray.swift
+++ b/packages/expo-modules-jsi/apple/Sources/ExpoModulesJSI/Runtime/Values/JavaScriptArray.swift
@@ -256,6 +256,16 @@ public struct JavaScriptArray: JavaScriptType, ~Copyable {
return JavaScriptValue(runtime, pointee.getValueAtIndex(runtime.pointee, index))
}
+ /**
+ Returns the value at the given index without bounds-checking. Used by the iteration
+ helpers (`map`, `forEach`, `reduce`, `filter`, `enumerated`) which iterate
+ `0.. JavaScriptValue {
+ return JavaScriptValue(runtime, pointee.getValueAtIndex(runtime.pointee, index))
+ }
+
/**
Sets the element at the specified index.
@@ -446,10 +456,17 @@ public struct JavaScriptArray: JavaScriptType, ~Copyable {
pattern, meaning it only throws if the transform closure throws.
*/
public func map(_ transform: (_ value: JavaScriptValue) throws -> T) rethrows -> [T] {
- return try (0.. [(offset: Int, element: JavaScriptValue)] {
- return (0.. Void) rethrows {
- for index in 0.. Bool) rethrows -> [JavaScriptValue] {
+ guard let runtime else {
+ FatalError.runtimeLost()
+ }
+ let count = self.length
var result: [JavaScriptValue] = []
- for index in 0..(_ initialResult: Result, _ nextPartialResult: (Result, JavaScriptValue) throws -> Result) rethrows -> Result {
+ guard let runtime else {
+ FatalError.runtimeLost()
+ }
+ let count = self.length
var result = initialResult
- for index in 0.. "$GENERATED_MODULE_MAP" <&2
+ exit 1
+fi
+if [[ ! -d "$PODS_ROOT" ]]; then
+ echo "error: PODS_ROOT does not exist: $PODS_ROOT" >&2
+ exit 1
+fi
+PODS_ROOT="$(cd "$PODS_ROOT" && pwd)"
+
+GENERATED_DIR="${PACKAGE_DIR}/.generated"
+GENERATED_MODULE_MAP="${GENERATED_DIR}/module.modulemap"
+mkdir -p "$GENERATED_DIR"
+
+# Avoid touching the file when contents would be identical, so the xcframework
+# hash cache and Xcode don't see a spurious change when PODS_ROOT is unchanged.
+NEW_CONTENT="module jsi {
+ umbrella header \"${PODS_ROOT}/Headers/Public/React-jsi/jsi/jsi.h\"
+
+ export *
+ module * { export * }
+}"
+if [[ ! -f "$GENERATED_MODULE_MAP" ]] || [[ "$(cat "$GENERATED_MODULE_MAP")" != "$NEW_CONTENT" ]]; then
+ printf '%s\n' "$NEW_CONTENT" > "$GENERATED_MODULE_MAP"
+fi
diff --git a/packages/expo-modules-jsi/apple/scripts/test.sh b/packages/expo-modules-jsi/apple/scripts/test.sh
new file mode 100755
index 00000000000000..046f7844b28268
--- /dev/null
+++ b/packages/expo-modules-jsi/apple/scripts/test.sh
@@ -0,0 +1,123 @@
+#!/bin/bash
+#
+# Runs the ExpoModulesJSI test suite against a host app's Pods.
+#
+# Tests need an iOS Simulator destination because the package's headers and
+# prebuilt xcframeworks (React, hermesvm, ReactNativeDependencies) only ship
+# iOS slices; macOS won't link.
+#
+# This script wires up a host-free test run by:
+# 1. Pointing PODS_ROOT at an installed app (defaults to apps/bare-expo).
+# 2. Symlinking React/hermesvm/ReactNativeDependencies xcframeworks from
+# Pods into `.test-frameworks/` so SwiftPM can register them as
+# relative-path binary targets (Package.swift picks them up).
+# 3. Generating the `jsi` Clang module map against PODS_ROOT.
+# 4. Invoking `xcodebuild test` against an iOS Simulator destination. Extra
+# args are forwarded to xcodebuild (e.g. `-only-testing TestName`).
+#
+# Usage:
+# scripts/test.sh [extra xcodebuild args...]
+#
+# Environment:
+# PODS_ROOT Path to a Pods directory with prebuilt React Native.
+# Defaults to $EXPO_ROOT_DIR/apps/bare-expo/ios/Pods.
+# DESTINATION xcodebuild -destination value. Defaults to the booted
+# simulator, or an iPhone on the highest-versioned
+# available iOS runtime.
+
+set -eo pipefail
+
+PACKAGE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+
+# --- PODS_ROOT ---
+#
+# Prefer an explicit PODS_ROOT (e.g. to test against a different app's Pods).
+# Otherwise fall back to bare-expo under EXPO_ROOT_DIR (set by the repo's
+# direnv config). Without either, point at a likely repo root computed from
+# this script's location so the script still works outside a direnv shell.
+
+if [[ -z "${PODS_ROOT:-}" ]]; then
+ : "${EXPO_ROOT_DIR:=$(cd "${PACKAGE_DIR}/../../.." && pwd)}"
+ PODS_ROOT="${EXPO_ROOT_DIR}/apps/bare-expo/ios/Pods"
+fi
+if [[ ! -d "$PODS_ROOT" ]]; then
+ echo "error: PODS_ROOT does not exist: $PODS_ROOT" >&2
+ echo " Run \`pod install\` in apps/bare-expo/ios first, or set PODS_ROOT explicitly." >&2
+ exit 1
+fi
+PODS_ROOT="$(cd "$PODS_ROOT" && pwd)"
+# Export so xcodebuild forwards it to SwiftPM's manifest evaluation, where
+# Package.swift reads it via resolvePodsRoot().
+export PODS_ROOT
+
+# --- Symlink xcframeworks for SwiftPM binary targets ---
+
+TEST_FRAMEWORKS_DIR="${PACKAGE_DIR}/.test-frameworks"
+mkdir -p "$TEST_FRAMEWORKS_DIR"
+
+link_xcframework() {
+ local name="$1"
+ local source="$2"
+ local link="${TEST_FRAMEWORKS_DIR}/${name}.xcframework"
+
+ if [[ ! -d "$source" ]]; then
+ echo "warning: missing $source β tests may fail to link" >&2
+ return
+ fi
+
+ # Recreate the symlink unconditionally so it tracks the current PODS_ROOT.
+ rm -f "$link"
+ ln -s "$source" "$link"
+}
+
+link_xcframework "React" \
+ "${PODS_ROOT}/React-Core-prebuilt/React.xcframework"
+link_xcframework "hermesvm" \
+ "${PODS_ROOT}/hermes-engine/destroot/Library/Frameworks/universal/hermesvm.xcframework"
+link_xcframework "ReactNativeDependencies" \
+ "${PODS_ROOT}/ReactNativeDependencies/framework/packages/react-native/ReactNativeDependencies.xcframework"
+
+# --- Generate the jsi module map ---
+
+"${PACKAGE_DIR}/scripts/generate-modulemap.sh"
+
+# --- Pick a simulator destination ---
+
+if [[ -z "${DESTINATION:-}" ]]; then
+ # Prefer a booted simulator if one's already running; otherwise pick an
+ # iPhone on the highest-versioned available iOS runtime.
+ # `|| true` keeps `set -e -o pipefail` from aborting when grep finds nothing.
+ BOOTED_ID=$( { xcrun simctl list devices booted 2>/dev/null \
+ | grep -oE '\(([0-9A-F-]{36})\)' | head -1 | tr -d '()'; } || true)
+ if [[ -n "$BOOTED_ID" ]]; then
+ DESTINATION="platform=iOS Simulator,id=$BOOTED_ID"
+ else
+ LATEST_ID=$( { xcrun simctl list devices available 2>/dev/null \
+ | awk '
+ /^-- iOS [0-9]/ { runtime = $3; next }
+ runtime && /iPhone / && match($0, /\([0-9A-F-]+\)/) {
+ print runtime, substr($0, RSTART + 1, RLENGTH - 2)
+ }
+ ' \
+ | sort -V -r \
+ | head -1 \
+ | awk '{ print $2 }'; } || true)
+ if [[ -z "$LATEST_ID" ]]; then
+ echo "error: no iOS Simulator available; boot one or set DESTINATION" >&2
+ exit 1
+ fi
+ DESTINATION="platform=iOS Simulator,id=$LATEST_ID"
+ fi
+fi
+
+# --- Run the tests ---
+
+cd "$PACKAGE_DIR"
+exec xcodebuild test \
+ -scheme ExpoModulesJSI \
+ -destination "$DESTINATION" \
+ -derivedDataPath "${PACKAGE_DIR}/.DerivedData" \
+ -disableAutomaticPackageResolution \
+ -skipPackagePluginValidation \
+ -skipMacroValidation \
+ "$@"
diff --git a/packages/expo-modules-jsi/package.json b/packages/expo-modules-jsi/package.json
index 4246fb5beddc23..ed001c6d6fe96c 100644
--- a/packages/expo-modules-jsi/package.json
+++ b/packages/expo-modules-jsi/package.json
@@ -10,7 +10,10 @@
"default": "./index.js"
}
},
- "scripts": {},
+ "scripts": {
+ "build": "apple/scripts/build-xcframework.sh",
+ "test": "apple/scripts/test.sh"
+ },
"keywords": [
"expo",
"modules",
diff --git a/packages/expo-router/CHANGELOG.md b/packages/expo-router/CHANGELOG.md
index c2ec70476884e3..fa3e709a169c93 100644
--- a/packages/expo-router/CHANGELOG.md
+++ b/packages/expo-router/CHANGELOG.md
@@ -36,6 +36,7 @@
- Fix `Stack.Screen.Title` string/number concatenation. ([#44213](https://github.com/expo/expo/pull/44213) by [@jakex7](https://github.com/jakex7))
- [android] Use `tint` prop name instead of `tintColor` for Jetpack Compose icons ([#44427](https://github.com/expo/expo/pull/44427) by [@hassankhan](https://github.com/hassankhan))
- Disable touch events on unfocused native tab screens ([#44778](https://github.com/expo/expo/pull/44778) by [@cortinico](https://github.com/cortinico))
+- fix RNScreensTabCompat to be compatible with new RNS version ([#45321](https://github.com/expo/expo/pull/45321) by [@Ubax](https://github.com/Ubax))
### π‘ Others
diff --git a/packages/expo-router/ios/LinkPreview/LinkPreviewNativeNavigation.swift b/packages/expo-router/ios/LinkPreview/LinkPreviewNativeNavigation.swift
index 268716b1c93c48..6dc1990f2408c2 100644
--- a/packages/expo-router/ios/LinkPreview/LinkPreviewNativeNavigation.swift
+++ b/packages/expo-router/ios/LinkPreview/LinkPreviewNativeNavigation.swift
@@ -150,10 +150,10 @@ internal class LinkPreviewNativeNavigation {
if let result =
enumeratedViews
.first(where: { _, view in
- guard let tabKey = RNScreensTabCompat.tabKey(from: view) else {
+ guard let screenKey = RNScreensTabCompat.screenKey(from: view) else {
return false
}
- return tabKeys.contains(tabKey)
+ return tabKeys.contains(screenKey)
}) {
return (result.offset, result.element)
}
@@ -179,8 +179,8 @@ internal class LinkPreviewNativeNavigation {
return view
}
} else if let nextView = nextResponder as? UIView,
- let tabKey = RNScreensTabCompat.tabKey(from: nextView),
- tabKeys.contains(tabKey) {
+ let screenKey = RNScreensTabCompat.screenKey(from: nextView),
+ tabKeys.contains(screenKey) {
return nextView
}
currentResponder = nextResponder
diff --git a/packages/expo-router/ios/LinkPreview/RNScreensTabCompat.swift b/packages/expo-router/ios/LinkPreview/RNScreensTabCompat.swift
index 78712a6566f507..826624d638bcf9 100644
--- a/packages/expo-router/ios/LinkPreview/RNScreensTabCompat.swift
+++ b/packages/expo-router/ios/LinkPreview/RNScreensTabCompat.swift
@@ -4,26 +4,26 @@ import UIKit
/// we detect tab views by checking `responds(to:)` for expected selectors
/// and read properties via KVC.
enum RNScreensTabCompat {
- private static let tabKeyName = "tabKey"
+ private static let screenKeyName = "screenKey"
private static let controllerName = "controller"
private static let reactViewControllerName = "reactViewController"
- private static let tabKeySelector = NSSelectorFromString(tabKeyName)
+ private static let screenKeySelector = NSSelectorFromString(screenKeyName)
private static let controllerSelector = NSSelectorFromString(controllerName)
private static let reactViewControllerSelector = NSSelectorFromString(reactViewControllerName)
// MARK: - Type check
- /// A view is a tab screen if it has a `tabKey` property β specific to RNScreens tab views.
+ /// A view is a tab screen if it has a `screenKey` property β specific to RNScreens tab views.
static func isTabScreen(_ view: UIView) -> Bool {
- view.responds(to: tabKeySelector)
+ view.responds(to: screenKeySelector)
}
// MARK: - Property access via KVC
- static func tabKey(from view: UIView) -> String? {
- guard view.responds(to: tabKeySelector) else { return nil }
- return view.value(forKey: tabKeyName) as? String
+ static func screenKey(from view: UIView) -> String? {
+ guard view.responds(to: screenKeySelector) else { return nil }
+ return view.value(forKey: screenKeyName) as? String
}
/// Calls `reactViewController()` dynamically via `perform(_:)`, then returns `.tabBarController`.
diff --git a/packages/expo-router/ios/Tests/RNScreensTabCompatTests.swift b/packages/expo-router/ios/Tests/RNScreensTabCompatTests.swift
index d56c86b11a051f..3ac64c97c3700f 100644
--- a/packages/expo-router/ios/Tests/RNScreensTabCompatTests.swift
+++ b/packages/expo-router/ios/Tests/RNScreensTabCompatTests.swift
@@ -5,9 +5,9 @@ import UIKit
// MARK: - Mock views
-/// Mock tab screen: has @objc tabKey property, mimicking RNScreens tab screen views.
+/// Mock tab screen: has @objc screenKey property, mimicking RNScreens tab screen views.
private class MockTabScreenView: UIView {
- @objc var tabKey: String?
+ @objc var screenKey: String?
}
/// Mock tab host: has @objc controller property, mimicking RNScreens tab host views.
@@ -22,7 +22,7 @@ private class MockTabHostWithBadController: UIView {
/// Mock view with a reactViewController() method that returns a UIViewController.
private class MockTabScreenWithReactVC: UIView {
- @objc var tabKey: String?
+ @objc var screenKey: String?
private var _reactViewController: UIViewController?
func configure(reactViewController: UIViewController) {
@@ -62,27 +62,27 @@ struct RNScreensTabCompatUnitTests {
}
}
- @Suite("tabKey")
+ @Suite("screenKey")
@MainActor
- struct TabKey {
+ struct ScreenKey {
@Test
func `reads value`() {
let tabScreen = MockTabScreenView()
- tabScreen.tabKey = "home"
- #expect(RNScreensTabCompat.tabKey(from: tabScreen) == "home")
+ tabScreen.screenKey = "home"
+ #expect(RNScreensTabCompat.screenKey(from: tabScreen) == "home")
}
@Test
- func `returns nil for nil tab key`() {
+ func `returns nil for nil screen key`() {
let tabScreen = MockTabScreenView()
- tabScreen.tabKey = nil
- #expect(RNScreensTabCompat.tabKey(from: tabScreen) == nil)
+ tabScreen.screenKey = nil
+ #expect(RNScreensTabCompat.screenKey(from: tabScreen) == nil)
}
@Test
func `returns nil for plain UIView`() {
let plainView = UIView()
- #expect(RNScreensTabCompat.tabKey(from: plainView) == nil)
+ #expect(RNScreensTabCompat.screenKey(from: plainView) == nil)
}
}
@@ -134,7 +134,7 @@ struct RNScreensTabCompatUnitTests {
tabBarController.viewControllers = [childVC]
let mockView = MockTabScreenWithReactVC()
- mockView.tabKey = "tab1"
+ mockView.screenKey = "tab1"
mockView.configure(reactViewController: childVC)
childVC.view.addSubview(mockView)
@@ -145,16 +145,16 @@ struct RNScreensTabCompatUnitTests {
@Test
func `returns nil when reactViewController returns nil`() {
let mockView = MockTabScreenWithReactVC()
- mockView.tabKey = "tab1"
+ mockView.screenKey = "tab1"
// Don't configure β reactViewController() returns nil
#expect(RNScreensTabCompat.tabBarController(fromTabScreen: mockView) == nil)
}
@Test
func `returns nil when no reactViewController method`() {
- // MockTabScreenView has tabKey but no reactViewController() method
+ // MockTabScreenView has screenKey but no reactViewController() method
let mockView = MockTabScreenView()
- mockView.tabKey = "tab1"
+ mockView.screenKey = "tab1"
#expect(RNScreensTabCompat.tabBarController(fromTabScreen: mockView) == nil)
}
@@ -171,7 +171,7 @@ struct RNScreensTabCompatUnitTests {
navController.viewControllers = [childVC]
let mockView = MockTabScreenWithReactVC()
- mockView.tabKey = "tab1"
+ mockView.screenKey = "tab1"
mockView.configure(reactViewController: childVC)
childVC.view.addSubview(mockView)
@@ -187,7 +187,7 @@ struct RNScreensTabCompatUnitTests {
struct RNScreensAPIContractTests {
@Test
- func `tab screen class responds to tabKey`() throws {
+ func `tab screen class responds to screenKey`() throws {
let cls = NSClassFromString("RNSTabsScreenComponentView")
?? NSClassFromString("RNSBottomTabsScreenComponentView")
guard let cls else {
@@ -195,7 +195,7 @@ struct RNScreensAPIContractTests {
return
}
let view = try #require((cls as? UIView.Type)?.init(), "Failed to instantiate tab screen class")
- #expect(view.responds(to: NSSelectorFromString("tabKey")))
+ #expect(view.responds(to: NSSelectorFromString("screenKey")))
}
@Test
diff --git a/tools/src/check-packages/checkPackageAsync.ts b/tools/src/check-packages/checkPackageAsync.ts
index f560e5c6821c1b..8ff8d83d69484c 100644
--- a/tools/src/check-packages/checkPackageAsync.ts
+++ b/tools/src/check-packages/checkPackageAsync.ts
@@ -10,6 +10,11 @@ import { Package } from '../Packages';
const { green } = chalk;
+/**
+ * Native-only packages that shouldn't go through these checks.
+ */
+const NATIVE_ONLY_PACKAGES = ['expo-modules-jsi'];
+
/**
* Known packages that fail to test on Windows.
* TODO: Fix breaking tests on Windows and remove packages from this list.
@@ -40,6 +45,10 @@ export default async function checkPackageAsync(
pkg: Package,
options: ActionOptions
): Promise {
+ if (NATIVE_ONLY_PACKAGES.includes(pkg.packageName)) {
+ logger.warn(`π« Skipping checks for ${green.bold(pkg.packageName)} (native-only package)`);
+ return true;
+ }
try {
switch (options.checkPackageType) {
case 'package':