diff --git a/packages/expo-modules-core/CHANGELOG.md b/packages/expo-modules-core/CHANGELOG.md index dbb3567813fab3..a927f70d984141 100644 --- a/packages/expo-modules-core/CHANGELOG.md +++ b/packages/expo-modules-core/CHANGELOG.md @@ -19,6 +19,7 @@ - Update edge-to-edge package to call `updateEdgeToEdgeFeatureFlag` ([#46335](https://github.com/expo/expo/pull/46335) by [@zoontek](https://github.com/zoontek)) - `NativeArrayBuffer` arguments no longer copy the buffer when it's already native-backed. ([#46448](https://github.com/expo/expo/pull/46448) by [@barthap](https://github.com/barthap)) +- [iOS] `SharedObject::NativeState` now derives from `expo::NativeState` so the Swift wrapper can be recovered from the JS side via `getNativeState`, laying the groundwork for native-state-based shared object lookup. ([#46330](https://github.com/expo/expo/pull/46330) by [@tsapeta](https://github.com/tsapeta)) ## 56.0.13 — 2026-05-26 diff --git a/packages/expo-modules-core/common/cpp/EventEmitter.cpp b/packages/expo-modules-core/common/cpp/EventEmitter.cpp index 778c9a3f59c2ef..a9d97d5b2d4c4b 100644 --- a/packages/expo-modules-core/common/cpp/EventEmitter.cpp +++ b/packages/expo-modules-core/common/cpp/EventEmitter.cpp @@ -90,7 +90,12 @@ void Listeners::call(jsi::Runtime &runtime, const std::string& eventName, const #pragma mark - NativeState -NativeState::NativeState() : jsi::NativeState() {} +NativeState::NativeState(void *context, void (*contextDeallocator)(void *)) +#if __has_include() + : NativeStateBase(context, contextDeallocator) {} +#else + : NativeStateBase() {} +#endif NativeState::~NativeState() { listeners.clear(); diff --git a/packages/expo-modules-core/common/cpp/EventEmitter.h b/packages/expo-modules-core/common/cpp/EventEmitter.h index 55db7dd34d69bf..1d01dc543389ac 100644 --- a/packages/expo-modules-core/common/cpp/EventEmitter.h +++ b/packages/expo-modules-core/common/cpp/EventEmitter.h @@ -6,6 +6,13 @@ #include #include +// Apple ships ExpoModulesJSI; non-Apple platforms (Android) don't, so the +// EventEmitter native state falls back to inheriting from `jsi::NativeState` +// directly when this header isn't available. +#if __has_include() +#include +#endif + namespace jsi = facebook::jsi; namespace expo::EventEmitter { @@ -68,14 +75,29 @@ class Listeners { void call(jsi::Runtime &runtime, const std::string& eventName, const jsi::Object &thisObject, const jsi::Value *args, size_t count) noexcept; }; +// Apple platforms ship `expo::NativeState`, which carries an opaque context pointer +// (used to round-trip through Swift's `JavaScriptNativeState`). On other platforms +// the native state inherits directly from `jsi::NativeState`; the context args +// passed to the constructor are ignored there. +#if __has_include() +using NativeStateBase = expo::NativeState; +#else +using NativeStateBase = facebook::jsi::NativeState; +#endif + /** Class representing a native state of objects that emit events. */ -class JSI_EXPORT NativeState : public jsi::NativeState { +class JSI_EXPORT NativeState : public NativeStateBase { public: using Shared = std::shared_ptr; - NativeState(); + /** + The `context` and `contextDeallocator` are forwarded to `expo::NativeState` + on Apple platforms so the JS-side `getNativeState` can round-trip back to a + Swift wrapper. They are ignored on platforms without ExpoModulesJSI. + */ + explicit NativeState(void *context = nullptr, void (*contextDeallocator)(void *) = nullptr); ~NativeState() override; /** diff --git a/packages/expo-modules-core/common/cpp/SharedObject.cpp b/packages/expo-modules-core/common/cpp/SharedObject.cpp index c49385207bb77e..702e13fb619488 100644 --- a/packages/expo-modules-core/common/cpp/SharedObject.cpp +++ b/packages/expo-modules-core/common/cpp/SharedObject.cpp @@ -7,8 +7,13 @@ namespace expo::SharedObject { #pragma mark - NativeState -NativeState::NativeState(ObjectId objectId, ObjectReleaser releaser) -: EventEmitter::NativeState(), objectId(objectId), releaser(std::move(releaser)) {} +NativeState::NativeState(ObjectId objectId, + ObjectReleaser releaser, + void *context, + void (*contextDeallocator)(void *)) +: EventEmitter::NativeState(context, contextDeallocator), + objectId(objectId), + releaser(std::move(releaser)) {} NativeState::~NativeState() { releaser(objectId); diff --git a/packages/expo-modules-core/common/cpp/SharedObject.h b/packages/expo-modules-core/common/cpp/SharedObject.h index 7ff2b52b83e793..a14a106c062c29 100644 --- a/packages/expo-modules-core/common/cpp/SharedObject.h +++ b/packages/expo-modules-core/common/cpp/SharedObject.h @@ -48,9 +48,15 @@ class JSI_EXPORT NativeState : public EventEmitter::NativeState { const ObjectReleaser releaser; /** - The default constructor that initializes a native state for the shared object with given ID. + Initializes a native state for the shared object with the given ID. The `context` + and `contextDeallocator` are forwarded to the base so the JS-side `getNativeState` + can round-trip back to a Swift wrapper on Apple; ignored on platforms without + ExpoModulesJSI. */ - NativeState(ObjectId objectId, ObjectReleaser releaser); + NativeState(ObjectId objectId, + ObjectReleaser releaser, + void *context = nullptr, + void (*contextDeallocator)(void *) = nullptr); ~NativeState() override; }; // class NativeState diff --git a/packages/expo-modules-core/ios/Core/SharedObjects/SharedObjectRegistry.swift b/packages/expo-modules-core/ios/Core/SharedObjects/SharedObjectRegistry.swift index bb2bdcdc3b93ae..c02278a61bc87e 100644 --- a/packages/expo-modules-core/ios/Core/SharedObjects/SharedObjectRegistry.swift +++ b/packages/expo-modules-core/ios/Core/SharedObjects/SharedObjectRegistry.swift @@ -130,16 +130,22 @@ public final class SharedObjectRegistry: Sendable { // Attach the C++ shared-object native state. Because `expo::SharedObject::NativeState` // inherits from `expo::EventEmitter::NativeState`, later `addListener` calls see an // existing native state (via the inheritance check) and don't overwrite it. - try? appContext?.runtime.withUnsafePointee { runtimePointer in - jsObject.asValue().withUnsafePointee { valuePointer in - SharedObjectUtils.setNativeState( - runtimePointer: runtimePointer, - valuePointer: UnsafeMutableRawPointer(mutating: valuePointer), - objectId: id, - releaser: delete(_:) - ) - } + let releaser: ObjectReleaser = { [weak self] id in + self?.delete(id) + } + let nativeState = JavaScriptNativeState { context, deallocator in + return SharedObjectUtils.makeSharedObjectNativeStatePtr( + objectId: id, + releaser: releaser, + context: context, + contextDeallocator: deallocator + ) } + // setNativeState calls acquireShared() synchronously, which retains `nativeState` + // via Unmanaged.passRetained. That's the wrapper's only strong reference — once + // the C++ pointee dies, the contextDeallocator releases it. The local going out + // of scope here is intentional. + jsObject.setNativeState(nativeState) return id } diff --git a/packages/expo-modules-core/ios/JS/EXSharedObjectUtils.h b/packages/expo-modules-core/ios/JS/EXSharedObjectUtils.h index ea0eb5199d12de..962bb765c623cd 100644 --- a/packages/expo-modules-core/ios/JS/EXSharedObjectUtils.h +++ b/packages/expo-modules-core/ios/JS/EXSharedObjectUtils.h @@ -10,18 +10,24 @@ NS_ASSUME_NONNULL_BEGIN Bridges Swift to `expo::SharedObject::NativeState` (which inherits from `expo::EventEmitter::NativeState`), so that later `addListener` calls on the same JS object find the existing state instead of overwriting it. - - TODO: remove once `EventEmitter`'s native state is migrated onto the Swift - `JavaScriptNativeState` / `expo::NativeState` system. */ NS_SWIFT_NAME(SharedObjectUtils) @interface EXSharedObjectUtils : NSObject -+ (void)setNativeState:(nonnull void *)runtimePointer - valuePointer:(nonnull void *)valuePointer - objectId:(long)objectId - releaser:(nonnull ObjectReleaser)releaser - NS_SWIFT_NAME(setNativeState(runtimePointer:valuePointer:objectId:releaser:)); +/** + Builds an `expo::SharedObject::NativeState` for the given `objectId` and returns + a heap-allocated `expo::NativeStateShared` (`std::shared_ptr`) + that owns it. The caller transfers ownership of the returned pointer to Swift via + `JavaScriptNativeState(adoptingFactory:)`, which consumes the heap allocation. + + The `context` and `contextDeallocator` are forwarded to `expo::NativeState` so + the JS-side `getNativeState` can later round-trip back to the Swift wrapper. + */ ++ (nonnull void *)makeSharedObjectNativeStatePtr:(long)objectId + releaser:(nonnull ObjectReleaser)releaser + context:(nullable void *)context + contextDeallocator:(nullable void (*)(void * _Nullable))contextDeallocator + NS_SWIFT_NAME(makeSharedObjectNativeStatePtr(objectId:releaser:context:contextDeallocator:)); @end diff --git a/packages/expo-modules-core/ios/JS/EXSharedObjectUtils.mm b/packages/expo-modules-core/ios/JS/EXSharedObjectUtils.mm index d6b7b23f783f09..2ef699e72266ab 100644 --- a/packages/expo-modules-core/ios/JS/EXSharedObjectUtils.mm +++ b/packages/expo-modules-core/ios/JS/EXSharedObjectUtils.mm @@ -3,21 +3,39 @@ #import #import +// Apple builds must see `` so `expo::NativeState` +// is the base of `expo::SharedObject::NativeState`. Without it, `EventEmitter.h` +// falls back to `facebook::jsi::NativeState` directly and silently drops the +// context/deallocator passed below — the retained Swift `JavaScriptNativeState` +// would leak and JS-side `getNativeState` recovery would never return its +// wrapper. The `__has_include` probe lives in `EventEmitter.h`; this assertion +// turns a misconfigured app build into a compile error instead. +#if !__has_include() +#error \ + "ExpoModulesJSI public headers are not visible to this translation unit. " \ + "Verify that the ExpoModulesJSI xcframework is linked and its `Headers/` " \ + "are on the framework search path." +#endif + @implementation EXSharedObjectUtils -+ (void)setNativeState:(void *)runtimePointer - valuePointer:(void *)valuePointer - objectId:(long)objectId - releaser:(ObjectReleaser)releaser ++ (void *)makeSharedObjectNativeStatePtr:(long)objectId + releaser:(ObjectReleaser)releaser + context:(void *)context + contextDeallocator:(void (*)(void *))contextDeallocator { - auto &runtime = *reinterpret_cast(runtimePointer); - auto &value = *reinterpret_cast(valuePointer); - auto object = value.getObject(runtime); - auto nativeState = std::make_shared( - objectId, - [releaser](long id) { releaser(id); } + // Heap-allocate the `shared_ptr` itself (not its pointee) so the + // raw pointer crosses the no-interop boundary into Swift, where `JavaScriptNativeState` + // adopts it: copies the shared_ptr into Swift-managed storage, then `delete`s this + // allocation. The shared control block created by `make_shared` is unaffected. + return new std::shared_ptr( + std::make_shared( + objectId, + [releaser](long id) { releaser(id); }, + context, + contextDeallocator + ) ); - object.setNativeState(runtime, nativeState); } @end diff --git a/packages/expo-modules-core/ios/Tests/SharedObjectRegistryTests.swift b/packages/expo-modules-core/ios/Tests/SharedObjectRegistryTests.swift new file mode 100644 index 00000000000000..ab2035b963826e --- /dev/null +++ b/packages/expo-modules-core/ios/Tests/SharedObjectRegistryTests.swift @@ -0,0 +1,101 @@ +// Copyright 2026-present 650 Industries. All rights reserved. + +import Testing + +@testable import ExpoModulesCore + +/** + New Swift Testing-based suite for `SharedObjectRegistry`. Coexists with + `SharedObjectRegistrySpec` (Quick/Nimble) until the rest of the spec is + migrated. + */ +@Suite("SharedObjectRegistry") +@JavaScriptActor +struct SharedObjectRegistryTests { + let appContext: AppContext + var runtime: ExpoRuntime { + get throws { + try appContext.runtime + } + } + var registry: SharedObjectRegistry { appContext.sharedObjectRegistry } + + init() { + self.appContext = AppContext.create() + } + + @Test + func `attaches an expo NativeState recoverable from the JS object`() throws { + let nativeObject = TestSharedObject() + let jsObject = try runtime.createObject() + registry.add(native: nativeObject, javaScript: jsObject) + + // Pins the cross-package contract that `SharedObject::NativeState` derives + // from `expo::NativeState` on Apple. If the `__has_include` probe in + // `EventEmitter.h` falls back, this returns false. + let hasNativeState = jsObject.hasNativeState() + #expect(hasNativeState) + } + + @Test + func `unsetting native state triggers automatic delete`() throws { + let nativeObject = TestSharedObject() + let jsObject = try runtime.createObject() + let id = registry.add(native: nativeObject, javaScript: jsObject) + #expect(registry.get(id) != nil) + + // Detach drops JSI's shared_ptr; the destructor's releaser calls `registry.delete(id)`. + jsObject.unsetNativeState() + try runtime.eval("gc() && gc() && gc()") + + #expect(registry.get(id) == nil) + #expect(nativeObject.sharedObjectId == 0) + } + + @Test + func `re-adding the same native object after deletion gets a fresh id`() throws { + let nativeObject = TestSharedObject() + let firstId = registry.add(native: nativeObject, javaScript: try runtime.createObject()) + registry.delete(firstId) + + let secondId = registry.add(native: nativeObject, javaScript: try runtime.createObject()) + #expect(secondId != firstId) + #expect(nativeObject.sharedObjectId == secondId) + } + + @Test + func `toNativeObject returns nil for an object without native state`() throws { + let jsObject = try runtime.createObject() + let result = registry.toNativeObject(jsObject) + #expect(result == nil) + } + + @Test + func `toNativeObject returns the paired native after add`() throws { + let nativeObject = TestSharedObject() + let jsObject = try runtime.createObject() + registry.add(native: nativeObject, javaScript: jsObject) + let result = registry.toNativeObject(jsObject) + #expect(result === nativeObject) + } + + @Test + func `releaser tolerates a deallocated registry`() throws { + // Build a throwaway registry, register an object on it, then drop the registry + // before triggering the releaser. The `[weak self]` capture should make the + // releaser a no-op rather than crashing on a dangling reference. + var localRegistry: SharedObjectRegistry? = SharedObjectRegistry(appContext: appContext) + let nativeObject = TestSharedObject() + let jsObject = try runtime.createObject() + localRegistry?.add(native: nativeObject, javaScript: jsObject) + + localRegistry = nil + + // The native state is still attached to `jsObject`. Detaching now would call + // the releaser whose `[weak self]` is now nil — must not crash. + jsObject.unsetNativeState() + try runtime.eval("gc() && gc() && gc()") + } +} + +private final class TestSharedObject: SharedObject {} diff --git a/packages/expo-modules-core/ios/Tests/SharedObjectTests.swift b/packages/expo-modules-core/ios/Tests/SharedObjectTests.swift index 6e5c2a49d5a386..a89382cbe64c93 100644 --- a/packages/expo-modules-core/ios/Tests/SharedObjectTests.swift +++ b/packages/expo-modules-core/ios/Tests/SharedObjectTests.swift @@ -130,6 +130,16 @@ struct SharedObjectTests { // MARK: - Native object + @Test + func `releases the native object when release() is called from JS`() throws { + let registrySizeBefore = appContext.sharedObjectRegistry.size + try runtime.eval([ + "sharedObject = new expo.modules.SharedObjectModule.SharedObjectExample()", + "sharedObject.release()" + ]) + #expect(appContext.sharedObjectRegistry.size == registrySizeBefore) + } + @Test func `emits events`() throws { // Create the shared object diff --git a/packages/expo-modules-jsi/CHANGELOG.md b/packages/expo-modules-jsi/CHANGELOG.md index 9efcee3d6644d1..2f607e7eb489d6 100644 --- a/packages/expo-modules-jsi/CHANGELOG.md +++ b/packages/expo-modules-jsi/CHANGELOG.md @@ -21,6 +21,7 @@ ### 💡 Others - `NativeArrayBuffer` arguments no longer copy the buffer when it's already native-backed. ([#46448](https://github.com/expo/expo/pull/46448) by [@barthap](https://github.com/barthap)) +- [iOS] `JavaScriptNativeState` can now back any `jsi::NativeState` subtype via a `void *` factory, so consumers without Swift/C++ interop (e.g. `expo-modules-core`) can supply their own pointee. `expo::NativeState` ships from the xcframework as a public C++ header. ([#46330](https://github.com/expo/expo/pull/46330) by [@tsapeta](https://github.com/tsapeta)) ## 56.0.7 — 2026-05-20 diff --git a/packages/expo-modules-jsi/CLAUDE.md b/packages/expo-modules-jsi/CLAUDE.md index 4da69140525b4f..16180a08d88030 100644 --- a/packages/expo-modules-jsi/CLAUDE.md +++ b/packages/expo-modules-jsi/CLAUDE.md @@ -22,13 +22,16 @@ apple/ │ │ │ └── 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 +│ ├── include/ # In-package C++ headers +│ │ └── Public/ # C++ headers shipped from the xcframework │ ├── JSIUtils.cpp │ └── TypedArray.cpp ├── Tests/ # Swift Testing suites, one per type ``` -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`. +In-package C++ headers (consumed by `ExpoModulesJSI`'s own Swift sources) live in `apple/Sources/ExpoModulesJSI-Cxx/include/`: `CppError.h`, `HostFunctionClosure.h`, `HostObject.h`, `HostObjectCallbacks.h`, `JSIUtils.h`, `MemoryBuffer.h`, `RetainedSwiftPointer.h`, `RuntimeScheduler.h`, `TypedArray.h`. + +Headers under `include/Public/` (today just `NativeState.h`) are additionally copied into the xcframework's `Headers/` directory by `build-xcframework.sh` and exposed via a `requires cplusplus` modulemap submodule, so non-interop C++ consumers (e.g. `expo-modules-core`) can include them via `` and use `__has_include` for graceful fallback on non-Apple platforms. 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. diff --git a/packages/expo-modules-jsi/README.md b/packages/expo-modules-jsi/README.md index d186bd719f899d..c8c60e44d649c5 100644 --- a/packages/expo-modules-jsi/README.md +++ b/packages/expo-modules-jsi/README.md @@ -16,7 +16,7 @@ This package has no JavaScript runtime code — it is consumed natively on i 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. +2. **C++ Utilities Layer** (`apple/Sources/ExpoModulesJSI-Cxx/`) — C++ helpers that bridge Swift and JSI. Most headers under `include/` are in-package only, but a small set under `include/Public/` (today just `NativeState.h`) is shipped from the xcframework so non-interop C++ consumers (e.g. `expo-modules-core`'s shared cross-platform sources) can include them via `` and probe their availability with `__has_include`. 3. **JSI / Hermes** — Binary xcframeworks (`React`, `hermes-engine`, `ReactNativeDependencies`) consumed as SPM binary targets. # Public API diff --git a/packages/expo-modules-jsi/apple/Package.swift b/packages/expo-modules-jsi/apple/Package.swift index ecd7ed6e940531..5798eafa2b4d57 100644 --- a/packages/expo-modules-jsi/apple/Package.swift +++ b/packages/expo-modules-jsi/apple/Package.swift @@ -125,7 +125,12 @@ let package = Package( name: "ExpoModulesJSI-Cxx", dependencies: [], cxxSettings: [ - .unsafeFlags(cxxIncludeFlags) + // Headers under `include/Public/` are shipped with the xcframework and + // consumable from outside the package. Adding this search path lets in-package + // code include them by basename (e.g. `#include "NativeState.h"`), which also + // matches how external consumers import them via ``. + .headerSearchPath("include/Public"), + .unsafeFlags(cxxIncludeFlags), ], ), diff --git a/packages/expo-modules-jsi/apple/Sources/ExpoModulesJSI-Cxx/include/JSIUtils.h b/packages/expo-modules-jsi/apple/Sources/ExpoModulesJSI-Cxx/include/JSIUtils.h index eb71ed1b15b5ab..4e12b0f66343d9 100644 --- a/packages/expo-modules-jsi/apple/Sources/ExpoModulesJSI-Cxx/include/JSIUtils.h +++ b/packages/expo-modules-jsi/apple/Sources/ExpoModulesJSI-Cxx/include/JSIUtils.h @@ -9,7 +9,7 @@ #include "CppError.h" #include "IRuntimeCompat.h" #include "MemoryBuffer.h" -#include "NativeState.h" +#include "Public/NativeState.h" #include "TypedArray.h" namespace jsi = facebook::jsi; @@ -248,23 +248,59 @@ inline bool hasNativeState(jsi::IRuntime &runtime, const jsi::Object &object) { return object.hasNativeState(runtime); } -inline void setNativeState(jsi::IRuntime &runtime, const jsi::Object &object, expo::NativeState &nativeState) { - std::shared_ptr nativeStatePtr = std::shared_ptr(&nativeState); - object.setNativeState(runtime, nativeStatePtr); +inline void setNativeState(jsi::IRuntime &runtime, const jsi::Object &object, const expo::NativeStateShared &nativeState) { + object.setNativeState(runtime, nativeState); } inline void unsetNativeState(jsi::IRuntime &runtime, const jsi::Object &object) { object.setNativeState(runtime, nullptr); } -inline expo::NativeState *_Nullable getNativeState(jsi::IRuntime &runtime, const jsi::Object &object) { +/** + Returns the `expo::NativeState` attached to the object, or `nullptr` if the object + has no native state, or its native state isn't an `expo::NativeState` subclass. + Adopted native states (those constructed in external C++ code) are recovered too + as long as their concrete type derives from `expo::NativeState`. + */ +inline expo::NativeState *_Nullable getExpoNativeState(jsi::IRuntime &runtime, const jsi::Object &object) { if (!object.hasNativeState(runtime)) { - // JSI's implementation asserts if `hasNativeState` returns true, but we prefer to make it nullable. return nullptr; } return object.getNativeState(runtime).get(); } +/** + Factory used by the default `JavaScriptNativeState` initializer. Builds a fresh + `expo::NativeState` with the given context/deallocator and returns it wrapped in + the `expo::NativeStateShared` typedef so Swift can hold it as a single value. + */ +inline expo::NativeStateShared makeExpoNativeStateShared(expo::NativeState::Context context, + expo::NativeState::ContextDeallocator deallocator) { + return std::make_shared(context, deallocator); +} + +/** + Builds an `expo::NativeStateWeak` from a strong `expo::NativeStateShared`. + Exists because Swift/C++ interop does not expose `std::weak_ptr`'s constructor + directly, so Swift can't write `expo.NativeStateWeak(shared)`. + */ +inline expo::NativeStateWeak makeNativeStateWeak(const expo::NativeStateShared &shared) { + return expo::NativeStateWeak(shared); +} + +/** + Moves the heap-allocated `expo::NativeStateShared` at `ptr` into a returned + by-value `NativeStateShared`, then `delete`s the heap allocation. Pairs with + `new expo::NativeStateShared(...)` performed by no-interop C++ consumers + (e.g. `EXSharedObjectUtils.mm`) — using Swift's `UnsafeMutablePointer.deallocate()` + on a pointer obtained from C++ `new` would be an allocator mismatch. + */ +inline expo::NativeStateShared consumeNativeStateSharedPtr(expo::NativeStateShared *ptr) { + expo::NativeStateShared shared = std::move(*ptr); + delete ptr; + return shared; +} + } // namespace expo #pragma clang assume_nonnull end diff --git a/packages/expo-modules-jsi/apple/Sources/ExpoModulesJSI-Cxx/include/NativeState.h b/packages/expo-modules-jsi/apple/Sources/ExpoModulesJSI-Cxx/include/NativeState.h deleted file mode 100644 index fe4acda0c77a12..00000000000000 --- a/packages/expo-modules-jsi/apple/Sources/ExpoModulesJSI-Cxx/include/NativeState.h +++ /dev/null @@ -1,28 +0,0 @@ -#pragma once - -#include -#include -#include - -#include "RetainedSwiftPointer.h" - -namespace expo { - -/** - Custom JSI native state that holds a pointer to `JavaScriptNativeState` in Swift. - */ -class NativeState final : public RetainedSwiftPointer, public facebook::jsi::NativeState { -public: - explicit NativeState(Context context, Deallocator deallocator) : RetainedSwiftPointer(context, deallocator) {} - - virtual ~NativeState() { - _deallocator(_context); - } - - inline Context getContext() { - return _context; - } - -} SWIFT_IMMORTAL_REFERENCE; // class NativeState - -} // namespace expo diff --git a/packages/expo-modules-jsi/apple/Sources/ExpoModulesJSI-Cxx/include/Public/NativeState.h b/packages/expo-modules-jsi/apple/Sources/ExpoModulesJSI-Cxx/include/Public/NativeState.h new file mode 100644 index 00000000000000..14b13eaaa538d9 --- /dev/null +++ b/packages/expo-modules-jsi/apple/Sources/ExpoModulesJSI-Cxx/include/Public/NativeState.h @@ -0,0 +1,94 @@ +#pragma once + +#include +#include + +// `SWIFT_RETURNS_INDEPENDENT_VALUE` tells Swift/C++ interop that `getContext()` +// hands back a value that doesn't borrow from the receiver — required so the +// method is importable on the now-move-only `expo::NativeState`. The bridging +// header lives in the Xcode toolchain include path and the macro expands to +// empty when interop isn't enabled, so non-interop C++ consumers can include +// this header normally. +#if __has_include() +#include +#else +#define SWIFT_RETURNS_INDEPENDENT_VALUE +#endif + +namespace expo { + +/** + Base class for `jsi::NativeState` instances that need to round-trip through + a Swift wrapper. Holds an opaque context pointer and a destructor callback + that runs when the underlying shared_ptr is released. The context pointer is + opaque to C++ — only the producer of the instance interprets it. + */ +class NativeState : public facebook::jsi::NativeState { +public: + using Context = void *; + using ContextDeallocator = void (*)(Context); + + explicit NativeState(Context context = nullptr, ContextDeallocator contextDeallocator = nullptr) + : _context(context), _contextDeallocator(contextDeallocator) {} + + // Move-only ownership: each instance's `_contextDeallocator(_context)` runs + // exactly once. The move ctor / assignment null the source's deallocator so + // the moved-from instance's destructor is a no-op. The user-declared move + // implicitly deletes the copy ctor and copy assignment, so copying is rejected + // at compile time (matching the contract) without ever spelling `= delete` — + // which would otherwise make Swift/C++ interop drop the imported type symbol. + NativeState(NativeState &&other) noexcept + : _context(other._context), _contextDeallocator(other._contextDeallocator) { + other._contextDeallocator = nullptr; + } + NativeState &operator=(NativeState &&other) noexcept { + if (this != &other) { + if (_contextDeallocator) { + _contextDeallocator(_context); + } + _context = other._context; + _contextDeallocator = other._contextDeallocator; + other._contextDeallocator = nullptr; + } + return *this; + } + + ~NativeState() override { + if (_contextDeallocator) { + _contextDeallocator(_context); + } + } + + /** + Returns the opaque context the producer baked into this instance. Only the + producer's consumers may interpret it; today every producer encodes a + retained Swift `JavaScriptNativeState`. If a future producer needs a + different encoding, switch to a tagged layout first so consumers can detect + mismatches instead of crashing. + */ + SWIFT_RETURNS_INDEPENDENT_VALUE + inline Context getContext() const { + return _context; + } + +private: + Context _context; + ContextDeallocator _contextDeallocator; +}; + +/** + Concrete `shared_ptr` specialization, exposed at namespace + scope so Swift/C++ interop can import it as `expo.NativeStateShared` — + interop does not support class templates directly, but a fully-specialized + typedef is fine. + */ +using NativeStateShared = std::shared_ptr; + +/** + Concrete `weak_ptr` specialization. Same rationale as + `NativeStateShared`. Lets Swift hold a non-owning reference to a pointee + whose lifetime is otherwise managed by JSI's slot(s). + */ +using NativeStateWeak = std::weak_ptr; + +} // namespace expo diff --git a/packages/expo-modules-jsi/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptNativeState.swift b/packages/expo-modules-jsi/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptNativeState.swift index 2121aa5032bd1b..dfe154c49024a1 100644 --- a/packages/expo-modules-jsi/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptNativeState.swift +++ b/packages/expo-modules-jsi/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptNativeState.swift @@ -3,74 +3,126 @@ internal import jsi /// Base class for JS object's native state. open class JavaScriptNativeState { - // Stored as an opaque pointer to avoid ARC retaining the bridged C++ type, - // which could cause memory leaks due to unbalanced reference counting. - private var _rawPointee: UnsafeMutableRawPointer? + /// Public-facing factory: builds a heap-allocated `expo::NativeStateShared` + /// wrapping a `jsi::NativeState` subclass that derives from `expo::NativeState`. + /// The factory is invoked lazily by `acquireShared` — i.e. only when the wrapper + /// is about to be attached to a JS object. Lazy invocation matters because the + /// factory's pointee may carry side-effecting destructors (e.g. SharedObject's + /// releaser) that must not fire until JSI has taken ownership. + /// + /// Each invocation gets a fresh Swift context (a retained `Unmanaged` pointer to + /// the wrapper) plus a deallocator that releases that pointer when the C++ + /// pointee dies. Both must be forwarded to the produced pointee. + public typealias Factory = ( + _ context: UnsafeMutableRawPointer, + _ deallocator: @convention(c) (UnsafeMutableRawPointer?) -> Void + ) -> UnsafeMutableRawPointer - internal private(set) var pointee: expo.NativeState? { - get { - _rawPointee.map { Unmanaged.fromOpaque($0).takeUnretainedValue() } - } - set { - _rawPointee = newValue.map { Unmanaged.passUnretained($0).toOpaque() } - } - } + /// Internal factory shape used by `acquireShared`. Returns the shared_ptr by value + /// so the default `init()` can use a built-in materialization without going through + /// a heap allocation; `init(factory:)` adapts the user-supplied `Factory` to this + /// shape by consuming the heap pointer. + private typealias InternalFactory = ( + _ context: UnsafeMutableRawPointer, + _ deallocator: @convention(c) (UnsafeMutableRawPointer?) -> Void + ) -> expo.NativeStateShared + + /// Non-owning reference to the underlying C++ `expo::NativeState`. `nil` until + /// the first `setNativeState` call materializes a pointee, and stays populated + /// afterward (though its contents may expire when the last JSI slot drops the + /// strong ref). `acquireShared()` transparently re-allocates if expired. + private var weakPointee: WeakPointer? = nil + + private let factory: InternalFactory public typealias Deallocator = (_ nativeState: JavaScriptNativeState) -> Void private var deallocator: Deallocator? = nil public init() { - // Get an opaque pointer to retained unmanaged instance. - let ptr = Unmanaged.passRetained(self).toOpaque() - - // Function called when the underlying `expo.NativeState` deallocates, - // e.g. when all JS objects using this native state gets garbage collected. - func deallocate(context: UnsafeMutableRawPointer) { - let unmanagedContext = Unmanaged.fromOpaque(context) - let nativeState = unmanagedContext.takeUnretainedValue() - - // Call the deallocator closure from Swift. - nativeState.deallocator.take()?(nativeState) - - // Release both C++ instance and unmanaged reference. - nativeState.pointee = nil - unmanagedContext.release() + self.factory = { context, contextDeallocator in + return expo.makeExpoNativeStateShared(context, contextDeallocator) } + } - // Create a native state in C++ that stores an opaque pointer to `self`. - self.pointee = expo.NativeState(ptr, deallocate) + /// Builds the underlying native state via a custom factory. See `Factory` for + /// the lazy-invocation contract. The factory must be safe to invoke more than + /// once: `acquireShared()` re-runs it when reattaching after the previous + /// pointee was released, so closures capturing one-shot identity (e.g. a + /// freshly-pulled registry id) should keep the wrapper alive for exactly one + /// attachment and let it deallocate afterward. + public init(factory: @escaping Factory) { + self.factory = { context, contextDeallocator in + let pointer = factory(context, contextDeallocator) + let typed = pointer.assumingMemoryBound(to: expo.NativeStateShared.self) + return expo.consumeNativeStateSharedPtr(typed) + } } - /// Checks whether the underlying native state has already been released. - public var isReleased: Bool { - return pointee == nil + /// Returns a `shared_ptr` to the underlying `expo::NativeState`, materializing + /// one lazily if there's no live pointee. Called by `JavaScriptObject.setNativeState` + /// to obtain the value to pass to JSI. + /// + /// Reads then writes `weakPointee` without synchronization — safe only because + /// every call site today runs on `JavaScriptActor`. A cross-runtime sharing + /// path (e.g. worklets attaching the same native state from a different + /// runtime/thread) must add locking before reusing this entry point. + internal func acquireShared() -> expo.NativeStateShared { + if let alive = weakPointee?.lock() { + return alive + } + let context = Unmanaged.passRetained(self).toOpaque() + let shared = factory(context, JavaScriptNativeState.contextDeallocator) + self.weakPointee = WeakPointer(shared) + return shared } - /// Sets a deallocator, a closure that is invoked when this native state is no longer attached to any JS object. - /// Replaces any previously set deallocator. - public func setDeallocator(_ deallocator: @escaping Deallocator) throws(NativeStateReleasedError) { - if isReleased { - throw NativeStateReleasedError() + private static let contextDeallocator: @convention(c) (UnsafeMutableRawPointer?) -> Void = { context in + guard let context else { + return } + let unmanagedContext = Unmanaged.fromOpaque(context) + let nativeState = unmanagedContext.takeUnretainedValue() + + nativeState.deallocator?(nativeState) + unmanagedContext.release() + } + + /// Sets a deallocator, a closure that is invoked when the underlying C++ pointee + /// dies, i.e. when this native state is no longer attached to any JS object. + /// Replaces any previously set deallocator. The same closure is used across + /// generations if the wrapper is reattached after a release. + public func setDeallocator(_ deallocator: @escaping Deallocator) { self.deallocator = deallocator } - /// Turns given C++ `expo.NativeState` into its Swift counterpart. - /// May return `nil` if the native state is of unrelated type. - internal static func from(cxx nativeState: expo.NativeState) -> Self? { - // Get the opaque pointer stored by the C++ native state. - let context = nativeState.getContext() - // Turn it to unmanaged reference to base `NativeState` type as `fromOpaque` may crash for unrelated types. + /// Turns the C++ `expo.NativeState` pointer into its Swift counterpart, or + /// returns `nil` if the context is null or the recovered instance's type + /// doesn't match `Self`. Precondition: a non-null context must be a retained + /// `JavaScriptNativeState` opaque pointer (see `Public/NativeState.h::getContext`); + /// any other layout will be reinterpreted and crash. + internal static func from(cxx nativeState: UnsafeMutablePointer) -> Self? { + guard let context = nativeState.pointee.getContext() else { + return nil + } let value = Unmanaged.fromOpaque(context).takeUnretainedValue() - // Then try to cast it to the proper type. return value as? Self } - // MARK: - Errors + // MARK: - WeakPointer + + /// Non-owning wrapper around `expo::NativeStateWeak`. `lock()` returns a strong + /// `shared_ptr` if the pointee is still alive, or `nil` if the last JSI slot has + /// already dropped it. + private struct WeakPointer { + let inner: expo.NativeStateWeak + + init(_ shared: borrowing expo.NativeStateShared) { + self.inner = expo.makeNativeStateWeak(shared) + } - public struct NativeStateReleasedError: Error, CustomStringConvertible { - public var description: String { - return "Native state is already released" + func lock() -> expo.NativeStateShared? { + let strong = inner.lock() + return strong.__convertToBool() ? strong : nil } } } diff --git a/packages/expo-modules-jsi/apple/Sources/ExpoModulesJSI/Runtime/Values/JavaScriptObject.swift b/packages/expo-modules-jsi/apple/Sources/ExpoModulesJSI/Runtime/Values/JavaScriptObject.swift index 68f3a39f725cef..e652e572648ce3 100644 --- a/packages/expo-modules-jsi/apple/Sources/ExpoModulesJSI/Runtime/Values/JavaScriptObject.swift +++ b/packages/expo-modules-jsi/apple/Sources/ExpoModulesJSI/Runtime/Values/JavaScriptObject.swift @@ -365,7 +365,12 @@ public struct JavaScriptObject: JavaScriptType, Sendable, ~Copyable { // MARK: - Native state - /// Returns whether this object has native state previously set by `setNativeState`. + /// Returns whether this object carries an `expo::NativeState`-derived JSI native + /// state — either one attached from Swift via `setNativeState`, or one produced + /// C++-side (e.g. via `addListener`'s `EventEmitter::NativeState`). A `true` + /// result does not imply that `getNativeState()` will return a non-nil Swift + /// wrapper: C++-produced states carry a null context and are unrecoverable from + /// the Swift side by design. public func hasNativeState() -> Bool { guard let runtime else { FatalError.runtimeLost() @@ -373,13 +378,15 @@ public struct JavaScriptObject: JavaScriptType, Sendable, ~Copyable { return expo.hasNativeState(runtime.pointee, pointee) } - /// Returns a native state previously set by `setNativeState`. - /// If `hasNativeState()` is false or object's native state is of unrelated type, this will return `nil`. + /// Returns the Swift `JavaScriptNativeState` wrapper attached to this object via + /// `setNativeState`, or `nil` if there is no native state, the state was + /// produced C++-side without a Swift back-pointer, or its concrete type doesn't + /// match `T`. public func getNativeState(as: T.Type = JavaScriptNativeState.self) -> T? { guard let runtime else { FatalError.runtimeLost() } - guard let cxxNativeState = expo.getNativeState(runtime.pointee, pointee) else { + guard let cxxNativeState = expo.getExpoNativeState(runtime.pointee, pointee) else { return nil } return T.from(cxx: cxxNativeState) @@ -388,16 +395,11 @@ public struct JavaScriptObject: JavaScriptType, Sendable, ~Copyable { /// Sets the internal native state property of this object, overwriting any old value. /// Creates a new shared_ptr to the object managed by state, which will live until the value at this property becomes unreachable. /// - TODO: throw a type error if this object is a proxy or host object. - public func setNativeState(_ nativeState: T) throws(JavaScriptNativeState - .NativeStateReleasedError) - { + public func setNativeState(_ nativeState: T) { guard let runtime else { FatalError.runtimeLost() } - guard let nativeStatePointee = nativeState.pointee else { - throw JavaScriptNativeState.NativeStateReleasedError() - } - expo.setNativeState(runtime.pointee, pointee, nativeStatePointee) + expo.setNativeState(runtime.pointee, self.pointee, nativeState.acquireShared()) } /// Unsets the native state of this object. diff --git a/packages/expo-modules-jsi/apple/Tests/JavaScriptNativeStateTests.swift b/packages/expo-modules-jsi/apple/Tests/JavaScriptNativeStateTests.swift index ae5bfdd25ae1b8..87998c8aaff075 100644 --- a/packages/expo-modules-jsi/apple/Tests/JavaScriptNativeStateTests.swift +++ b/packages/expo-modules-jsi/apple/Tests/JavaScriptNativeStateTests.swift @@ -19,29 +19,29 @@ struct JavaScriptNativeStateTests { } @Test - func `sets base native state`() throws { + func `sets base native state`() { let object = runtime.createObject() let nativeState = JavaScriptNativeState() - try object.setNativeState(nativeState) + object.setNativeState(nativeState) #expect(object.hasNativeState() == true) #expect(object.getNativeState() === nativeState) } @Test - func `sets custom native state`() throws { + func `sets custom native state`() { let object = runtime.createObject() let nativeState = CustomNativeState() - try object.setNativeState(nativeState) + object.setNativeState(nativeState) #expect(object.hasNativeState() == true) #expect(object.getNativeState() === nativeState) #expect(object.getNativeState(as: CustomNativeState.self)?.hello == "world") } @Test - func `getNativeState is nil for unrelated type`() throws { + func `getNativeState is nil for unrelated type`() { let object = runtime.createObject() let nativeState = CustomNativeState() - try object.setNativeState(nativeState) + object.setNativeState(nativeState) #expect(object.getNativeState(as: CustomNativeState.self) === nativeState) #expect(object.getNativeState(as: OtherNativeState.self) == nil) } @@ -50,7 +50,7 @@ struct JavaScriptNativeStateTests { func `unsets native state`() throws { let object = runtime.createObject() let nativeState = CustomNativeState() - try object.setNativeState(nativeState) + object.setNativeState(nativeState) #expect(object.hasNativeState() == true) object.unsetNativeState() #expect(object.hasNativeState() == false) @@ -58,59 +58,40 @@ struct JavaScriptNativeStateTests { } @Test - func `shares native state between objects`() throws { + func `shares native state between objects`() { let object1 = runtime.createObject() let object2 = runtime.createObject() let nativeState = CustomNativeState() - try object1.setNativeState(nativeState) - try object2.setNativeState(nativeState) + object1.setNativeState(nativeState) + object2.setNativeState(nativeState) #expect(object1.hasNativeState() == true) #expect(object2.hasNativeState() == true) #expect(object1.getNativeState() === object2.getNativeState()) } @Test - func `setNativeState throws when native state is released`() throws { - let object = runtime.createObject() + func `reattaches after the previous pointee was released`() throws { + let object1 = runtime.createObject() + let object2 = runtime.createObject() let nativeState = CustomNativeState() - try object.setNativeState(nativeState) - - // Unsetting releases the native state's underlying C++ object. - object.unsetNativeState() + object1.setNativeState(nativeState) + object1.unsetNativeState() - // Force garbage collection + // Force garbage collection so the previous C++ pointee is dropped. try runtime.eval("gc() && gc() && gc()") - #expect(nativeState.isReleased == true) - #expect(throws: JavaScriptNativeState.NativeStateReleasedError.self) { - try object.setNativeState(nativeState) - } + // Reattaching transparently materializes a fresh pointee. + object2.setNativeState(nativeState) + #expect(object2.hasNativeState() == true) + #expect(object2.getNativeState() === nativeState) } // MARK: - Deallocator @Test - func `sets deallocator on native state`() throws { + func `sets deallocator on native state`() { let nativeState = CustomNativeState() - try nativeState.setDeallocator { _ in } - } - - @Test - func `setDeallocator throws when native state is released`() throws { - let object = runtime.createObject() - let nativeState = CustomNativeState() - try object.setNativeState(nativeState) - - // Release the native state by unsetting it from the object. - object.unsetNativeState() - - // Force garbage collection - try runtime.eval("gc() && gc() && gc()") - - #expect(nativeState.isReleased == true) - #expect(throws: JavaScriptNativeState.NativeStateReleasedError.self) { - try nativeState.setDeallocator { _ in } - } + nativeState.setDeallocator { _ in } } @Test @@ -118,10 +99,10 @@ struct JavaScriptNativeStateTests { var deallocatorCalled = false let object = runtime.createObject() let nativeState = CustomNativeState() - try nativeState.setDeallocator { _ in + nativeState.setDeallocator { _ in deallocatorCalled = true } - try object.setNativeState(nativeState) + object.setNativeState(nativeState) object.unsetNativeState() // Force garbage collection @@ -130,19 +111,17 @@ struct JavaScriptNativeStateTests { #expect(deallocatorCalled == true) } - // TODO: Fix setNativeState in JSIUtils.h to share a single std::shared_ptr across objects - // instead of creating independent shared_ptrs from the same raw pointer. - @Test(.disabled("Each setNativeState creates an independent shared_ptr — unsetting one deallocates immediately")) + @Test func `deallocator is called once when shared native state is released`() throws { var deallocatorCallCount = 0 let object1 = runtime.createObject() let object2 = runtime.createObject() let nativeState = CustomNativeState() - try nativeState.setDeallocator { _ in + nativeState.setDeallocator { _ in deallocatorCallCount += 1 } - try object1.setNativeState(nativeState) - try object2.setNativeState(nativeState) + object1.setNativeState(nativeState) + object2.setNativeState(nativeState) // Unset from the first object — deallocator should not fire yet. object1.unsetNativeState() @@ -154,6 +133,111 @@ struct JavaScriptNativeStateTests { try runtime.eval("gc() && gc() && gc()") #expect(deallocatorCallCount == 1) } + + @Test + func `native state survives garbage collection while attached to a reachable object`() throws { + let object = runtime.createObject() + let nativeState = CustomNativeState() + object.setNativeState(nativeState) + + // GC shouldn't release the underlying C++ pointee while the JS object is reachable. + try runtime.eval("gc() && gc() && gc()") + + #expect(object.getNativeState() === nativeState) + } + + @Test + func `getNativeState recovers the same instance from multiple objects`() { + let object1 = runtime.createObject() + let object2 = runtime.createObject() + let nativeState = CustomNativeState() + object1.setNativeState(nativeState) + object2.setNativeState(nativeState) + + #expect(object1.getNativeState() === nativeState) + #expect(object2.getNativeState() === nativeState) + #expect(object1.getNativeState(as: CustomNativeState.self)?.hello == "world") + } + + @Test + func `setting a different native state replaces the previous one`() { + let object = runtime.createObject() + let first = CustomNativeState() + let second = CustomNativeState() + object.setNativeState(first) + object.setNativeState(second) + + #expect(object.getNativeState() === second) + #expect(object.getNativeState() !== first) + } + + @Test + func `deallocator fires on each generation after re-attach`() throws { + var deallocatorCallCount = 0 + let object1 = runtime.createObject() + let object2 = runtime.createObject() + let nativeState = CustomNativeState() + nativeState.setDeallocator { _ in + deallocatorCallCount += 1 + } + + object1.setNativeState(nativeState) + object1.unsetNativeState() + try runtime.eval("gc() && gc() && gc()") + #expect(deallocatorCallCount == 1) + + // Re-attaching builds a new C++ pointee; the wrapper-level deallocator + // remains set and must fire again when the new generation is released. + object2.setNativeState(nativeState) + #expect(object2.getNativeState() === nativeState) + + object2.unsetNativeState() + try runtime.eval("gc() && gc() && gc()") + #expect(deallocatorCallCount == 2) + } + + @Test + func `wrapper survives loss of swift strong refs while attached to a reachable object`() throws { + let object = runtime.createObject() + weak var weakWrapper: CustomNativeState? + + do { + let nativeState = CustomNativeState() + weakWrapper = nativeState + object.setNativeState(nativeState) + // `nativeState` goes out of scope here. The retained Unmanaged context + // baked into the C++ pointee is the only Swift-side strong reference. + } + + try runtime.eval("gc() && gc() && gc()") + + // The wrapper is still alive because the C++ pointee (held by JSI's slot) + // retains it via Unmanaged. `getNativeState` recovers it. + #expect(weakWrapper != nil) + #expect(object.getNativeState() === weakWrapper) + + // Once detached and GC'd, the C++ pointee dies, releases the Unmanaged, + // and the Swift wrapper finally deallocates. + object.unsetNativeState() + try runtime.eval("gc() && gc() && gc()") + #expect(weakWrapper == nil) + } + + @Test + func `unsetting one of two sharers leaves the other intact`() throws { + let object1 = runtime.createObject() + let object2 = runtime.createObject() + let nativeState = CustomNativeState() + object1.setNativeState(nativeState) + object2.setNativeState(nativeState) + + object1.unsetNativeState() + try runtime.eval("gc() && gc() && gc()") + + #expect(object1.hasNativeState() == false) + #expect(object2.hasNativeState() == true) + #expect(object2.getNativeState() === nativeState) + } } final class CustomNativeState: JavaScriptNativeState { diff --git a/packages/expo-modules-jsi/apple/scripts/build-xcframework.sh b/packages/expo-modules-jsi/apple/scripts/build-xcframework.sh index b86695668551f9..a65c1f44659365 100755 --- a/packages/expo-modules-jsi/apple/scripts/build-xcframework.sh +++ b/packages/expo-modules-jsi/apple/scripts/build-xcframework.sh @@ -281,7 +281,40 @@ build_slice() { local headers_dir="${content_root}/Headers" mkdir -p "$headers_dir" cp "${generated_maps}/${PACKAGE_NAME}-Swift.h" "$headers_dir/" - cp "${generated_maps}/${PACKAGE_NAME}.modulemap" "$headers_dir/module.modulemap" + + # Public C++ headers — every file under `Sources/${PACKAGE_NAME}-Cxx/include/Public/` + # is shipped in the framework's `Headers/` directory and consumable from external + # ObjC++ via `#import <${PACKAGE_NAME}/Header.h>`. They live in a `requires cplusplus` + # submodule so Swift consumers don't try to import them. + local public_cxx_dir="${PACKAGE_DIR}/Sources/${PACKAGE_NAME}-Cxx/include/Public" + local public_cxx_headers=() + while IFS= read -r -d '' file; do + public_cxx_headers+=("$(basename "$file")") + done < <(find "$public_cxx_dir" -maxdepth 1 -name '*.h' -print0) + if (( ${#public_cxx_headers[@]} )); then + for header in "${public_cxx_headers[@]}"; do + cp "${public_cxx_dir}/${header}" "$headers_dir/" + done + fi + + # Custom modulemap: keeps the Swift-generated header as the main module and + # exposes the public C++ headers as a `requires cplusplus` submodule. + { + echo "module ${PACKAGE_NAME} {" + echo " header \"${PACKAGE_NAME}-Swift.h\"" + echo " export *" + echo "" + echo " explicit module Cxx {" + echo " requires cplusplus" + if (( ${#public_cxx_headers[@]} )); then + for header in "${public_cxx_headers[@]}"; do + echo " header \"${header}\"" + done + fi + echo " export *" + echo " }" + echo "}" + } > "${headers_dir}/module.modulemap" # Add the top-level Headers/Modules symlinks expected on versioned macOS # frameworks. xcodebuild already set up ExpoModulesJSI -> Versions/Current/...