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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/expo-modules-core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 6 additions & 1 deletion packages/expo-modules-core/common/cpp/EventEmitter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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(<ExpoModulesJSI/NativeState.h>)
: NativeStateBase(context, contextDeallocator) {}
#else
: NativeStateBase() {}
#endif

NativeState::~NativeState() {
listeners.clear();
Expand Down
26 changes: 24 additions & 2 deletions packages/expo-modules-core/common/cpp/EventEmitter.h
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@
#include <list>
#include <jsi/jsi.h>

// 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(<ExpoModulesJSI/NativeState.h>)
#include <ExpoModulesJSI/NativeState.h>
#endif

namespace jsi = facebook::jsi;

namespace expo::EventEmitter {
Expand Down Expand Up @@ -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(<ExpoModulesJSI/NativeState.h>)
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>;

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;

/**
Expand Down
9 changes: 7 additions & 2 deletions packages/expo-modules-core/common/cpp/SharedObject.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
10 changes: 8 additions & 2 deletions packages/expo-modules-core/common/cpp/SharedObject.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
22 changes: 14 additions & 8 deletions packages/expo-modules-core/ios/JS/EXSharedObjectUtils.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<jsi::NativeState>`)
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

Expand Down
40 changes: 29 additions & 11 deletions packages/expo-modules-core/ios/JS/EXSharedObjectUtils.mm
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,39 @@
#import <ExpoModulesCore/EXSharedObjectUtils.h>
#import <ExpoModulesCore/SharedObject.h>

// Apple builds must see `<ExpoModulesJSI/NativeState.h>` 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(<ExpoModulesJSI/NativeState.h>)
#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<jsi::Runtime *>(runtimePointer);
auto &value = *reinterpret_cast<jsi::Value *>(valuePointer);
auto object = value.getObject(runtime);
auto nativeState = std::make_shared<expo::SharedObject::NativeState>(
objectId,
[releaser](long id) { releaser(id); }
// Heap-allocate the `shared_ptr<jsi::NativeState>` 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<jsi::NativeState>(
std::make_shared<expo::SharedObject::NativeState>(
objectId,
[releaser](long id) { releaser(id); },
context,
contextDeallocator
)
);
object.setNativeState(runtime, nativeState);
}

@end
101 changes: 101 additions & 0 deletions packages/expo-modules-core/ios/Tests/SharedObjectRegistryTests.swift
Original file line number Diff line number Diff line change
@@ -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 {}
10 changes: 10 additions & 0 deletions packages/expo-modules-core/ios/Tests/SharedObjectTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/expo-modules-jsi/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 5 additions & 2 deletions packages/expo-modules-jsi/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<ExpoModulesJSI/NativeState.h>` 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 &mdash; the actual implementation is entirely in `apple/`. The npm package has no JS runtime code; `index.js` exports null.

Expand Down
2 changes: 1 addition & 1 deletion packages/expo-modules-jsi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ This package has no JavaScript runtime code &mdash; it is consumed natively on i
Three-layer design bridging JSI C++ to Swift:

1. **Swift Layer** (`apple/Sources/ExpoModulesJSI/`) &mdash; 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<T>` to convert to reference semantics when needed (escaping closures, containers).
2. **C++ Utilities Layer** (`apple/Sources/ExpoModulesJSI-Cxx/`) &mdash; Internal C++ helpers that bridge Swift and JSI.
2. **C++ Utilities Layer** (`apple/Sources/ExpoModulesJSI-Cxx/`) &mdash; 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 `<ExpoModulesJSI/NativeState.h>` and probe their availability with `__has_include`.
3. **JSI / Hermes** &mdash; Binary xcframeworks (`React`, `hermes-engine`, `ReactNativeDependencies`) consumed as SPM binary targets.

# Public API
Expand Down
7 changes: 6 additions & 1 deletion packages/expo-modules-jsi/apple/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<ExpoModulesJSI/NativeState.h>`.
.headerSearchPath("include/Public"),
.unsafeFlags(cxxIncludeFlags),
],
),

Expand Down
Loading
Loading