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':