expo-modules-core 57.0.3 → 57.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,21 +12,6 @@ public typealias SharedObjectId = Int
12
12
  */
13
13
  let sharedObjectIdPropertyName = "__expo_shared_object_id__"
14
14
 
15
- /**
16
- A pair of matching native and JS objects. Uses a weak reference to the JS object
17
- so that the registry doesn't prevent JS garbage collection. The C++ NativeState
18
- deallocator removes the pair from the registry when the JS object is collected.
19
- */
20
- internal final class SharedObjectPair: @unchecked Sendable {
21
- let native: SharedObject
22
- let javaScript: JavaScriptWeakObject
23
-
24
- init(native: SharedObject, javaScript: consuming JavaScriptWeakObject) {
25
- self.native = native
26
- self.javaScript = javaScript
27
- }
28
- }
29
-
30
15
  /**
31
16
  The registry of shared objects.
32
17
  */
@@ -36,15 +21,20 @@ public final class SharedObjectRegistry: Sendable {
36
21
  */
37
22
  private weak let appContext: AppContext?
38
23
 
39
- internal struct State: Sendable {
24
+ // `@unchecked` because `SharedObjectNativeState` isn't `Sendable` (its JSI base class isn't), yet a
25
+ // value escapes `withLock` whenever `get`/the lookups return it. Access is always serialized through
26
+ // `state`'s `Mutex` and the callers run on `JavaScriptActor`, so the unchecked assertion is sound.
27
+ internal struct State: @unchecked Sendable {
40
28
  /**
41
- A dictionary of shared object pairs.
29
+ Maps each shared object id to the native object's `SharedObjectNativeState`, which carries the
30
+ native object and its per-runtime JS counterparts. The state is held strongly so an id stays
31
+ resolvable via `get(_:)` until an explicit `delete(_:)`, independent of JS garbage collection.
42
32
  */
43
- var pairs = [SharedObjectId: SharedObjectPair]()
33
+ var pairs = [SharedObjectId: SharedObjectNativeState]()
44
34
 
45
35
  /**
46
- The counter of IDs to assign to the shared object pairs.
47
- The next pair added to the registry will be saved using this ID.
36
+ The counter of IDs to assign to the shared objects.
37
+ The next object added to the registry will be saved using this ID.
48
38
  */
49
39
  var nextId: SharedObjectId = 1
50
40
  }
@@ -89,9 +79,10 @@ public final class SharedObjectRegistry: Sendable {
89
79
  }
90
80
 
91
81
  /**
92
- Returns a pair of shared objects with given ID or `nil` when there is no such pair in the registry.
82
+ Returns the native state registered under the given ID, or `nil` when there is no such entry.
83
+ The state carries the native object (`native`) and its per-runtime JS counterparts.
93
84
  */
94
- internal func get(_ id: SharedObjectId) -> SharedObjectPair? {
85
+ internal func get(_ id: SharedObjectId) -> SharedObjectNativeState? {
95
86
  return state.withLock { state in
96
87
  return state.pairs[id]
97
88
  }
@@ -102,7 +93,10 @@ public final class SharedObjectRegistry: Sendable {
102
93
  */
103
94
  @discardableResult
104
95
  internal func add(native nativeObject: SharedObject, javaScript jsObject: borrowing JavaScriptObject) -> SharedObjectId {
105
- let id = pullNextId()
96
+ // A native object that already carries a native state was paired in an earlier runtime. Reuse its
97
+ // id rather than minting a new one: the C++ `NativeState.objectId` is immutable and drives the
98
+ // releaser/`delete`, so a fresh id would disagree with it and leak this object's registry entry.
99
+ let id = nativeObject.nativeState != nil ? nativeObject.sharedObjectId : pullNextId()
106
100
 
107
101
  // Assign the ID and the app context to the object.
108
102
  nativeObject.sharedObjectId = id
@@ -122,32 +116,48 @@ public final class SharedObjectRegistry: Sendable {
122
116
  jsObject.setExternalMemoryPressure(memoryPressure)
123
117
  }
124
118
 
125
- // Save the pair in the dictionary with a weak reference to the JS object.
126
- state.withLock { state in
127
- state.pairs[id] = SharedObjectPair(native: nativeObject, javaScript: jsObject.createWeak())
128
- }
129
-
130
119
  // Attach the C++ shared-object native state. Because `expo::SharedObject::NativeState`
131
120
  // inherits from `expo::EventEmitter::NativeState`, later `addListener` calls see an
132
121
  // existing native state (via the inheritance check) and don't overwrite it.
133
- let releaser: ObjectReleaser = { [weak self] id in
134
- self?.delete(id)
135
- }
136
- let nativeState = SharedObjectNativeState(native: nativeObject) { context, deallocator in
137
- return SharedObjectUtils.makeSharedObjectNativeStatePtr(
138
- objectId: id,
139
- releaser: releaser,
140
- context: context,
141
- contextDeallocator: deallocator
142
- )
122
+ //
123
+ // A native object may already carry a native state from a previous pairing in another runtime.
124
+ // Reuse it so all runtimes share one native state (and one underlying C++ pointee, via
125
+ // `acquireShared`); a fresh state per runtime would leave `nativeObject.nativeState` pointing at
126
+ // whichever ran last and lose the earlier runtimes' pairings.
127
+ let nativeState = nativeObject.nativeState ?? {
128
+ let releaser: ObjectReleaser = { [weak self] id in
129
+ self?.delete(id)
130
+ }
131
+ let state = SharedObjectNativeState(native: nativeObject) { context, deallocator in
132
+ return SharedObjectUtils.makeSharedObjectNativeStatePtr(
133
+ objectId: id,
134
+ releaser: releaser,
135
+ context: context,
136
+ contextDeallocator: deallocator
137
+ )
138
+ }
139
+ nativeObject.nativeState = state
140
+ return state
141
+ }()
142
+
143
+ // Index the native state by id. One entry per native object: re-pairing in another runtime stores
144
+ // the same state under the same id (idempotent); the per-runtime JS counterparts live on the state.
145
+ state.withLock { state in
146
+ state.pairs[id] = nativeState
143
147
  }
148
+
144
149
  // setNativeState calls acquireShared() synchronously, which retains `nativeState`
145
150
  // via Unmanaged.passRetained. That's the wrapper's only strong reference — once
146
- // the C++ pointee dies, the contextDeallocator releases it. The local going out
147
- // of scope here is intentional.
151
+ // the C++ pointee dies, the contextDeallocator releases it. Reattaching the same
152
+ // native state to another runtime's JS object reuses that same C++ pointee.
148
153
  jsObject.setNativeState(nativeState)
149
- nativeState.pairedWeakObject = jsObject.createWeak()
150
- nativeObject.nativeState = nativeState
154
+
155
+ // The registry is scoped to its app context, so the JS object being paired here lives in that
156
+ // context's runtime. Record the pairing under that runtime so the native object can recover this
157
+ // JS counterpart via `native.nativeState?.javaScriptObject(in:)`.
158
+ if let runtime = try? appContext?.runtime {
159
+ nativeState.setJavaScriptObject(jsObject, in: runtime)
160
+ }
151
161
 
152
162
  return id
153
163
  }
@@ -157,14 +167,15 @@ public final class SharedObjectRegistry: Sendable {
157
167
  */
158
168
  internal func delete(_ id: SharedObjectId) {
159
169
  state.withLock { state in
160
- if let pair = state.pairs[id] {
161
- pair.native.sharedObjectWillRelease()
162
- // Reset an ID on the objects.
163
- pair.native.sharedObjectId = 0
170
+ if let nativeState = state.pairs[id] {
171
+ let native = nativeState.native
172
+ native.sharedObjectWillRelease()
173
+ // Reset an ID on the object.
174
+ native.sharedObjectId = 0
164
175
 
165
- // Delete the pair from the dictionary.
176
+ // Delete the entry from the dictionary.
166
177
  state.pairs[id] = nil
167
- pair.native.sharedObjectDidRelease()
178
+ native.sharedObjectDidRelease()
168
179
  }
169
180
  }
170
181
  }
@@ -200,25 +211,29 @@ public final class SharedObjectRegistry: Sendable {
200
211
  */
201
212
  @JavaScriptActor
202
213
  internal func toJavaScriptValue(sharedObjectId id: SharedObjectId) -> JavaScriptValue? {
203
- let pair = state.withLock { state in
214
+ guard let runtime = try? appContext?.runtime else {
215
+ return nil
216
+ }
217
+ let nativeState = state.withLock { state in
204
218
  return state.pairs[id]
205
219
  }
206
- return pair?.javaScript.lock()?.asValue()
220
+ return nativeState?.javaScriptObject(in: runtime)?.asValue()
207
221
  }
208
222
 
209
223
  /**
210
- Gets the JS shared object that is paired with a given native object.
224
+ Gets the JS shared object that is paired with a given native object in this registry's runtime.
211
225
  */
212
226
  internal func toJavaScriptObject(_ nativeObject: SharedObject) -> JavaScriptObject? {
213
- if let pairedObject = nativeObject.nativeState?.pairedWeakObject?.lock() {
214
- return pairedObject
227
+ guard let runtime = try? appContext?.runtime else {
228
+ return nil
215
229
  }
216
- // Fallback to the id-based lookup for cases where the native object was registered
217
- // through a path that doesn't attach a `SharedObjectNativeState`.
218
- let pair = state.withLock { state in
230
+ // The id table and the native object both resolve to the same `SharedObjectNativeState`, which
231
+ // owns the per-runtime JS counterparts, so a single lookup serves both. Prefer the native object's
232
+ // own back-pointer and fall back to the id table for objects whose back-pointer was cleared.
233
+ let nativeState = nativeObject.nativeState ?? state.withLock { state in
219
234
  return state.pairs[nativeObject.sharedObjectId]
220
235
  }
221
- return pair?.javaScript.lock()
236
+ return nativeState?.javaScriptObject(in: runtime)
222
237
  }
223
238
 
224
239
  /**
@@ -19,6 +19,18 @@ extension ExpoSwiftUI {
19
19
  func forceResignFirstResponder()
20
20
  }
21
21
 
22
+ /**
23
+ Protocol for virtual views that can resign the first responder anywhere within their subtree,
24
+ not just on the view itself. Unlike `FocusableView`, this walks nested children so a focused
25
+ field wrapped in a container (e.g. a `TextField` inside an `HStack` or `LabeledContent`) is
26
+ also blurred on unmount, which `FocusableView` alone would miss.
27
+ See https://github.com/expo/expo/issues/47682
28
+ */
29
+ internal protocol FocusableViewContainer {
30
+ @MainActor
31
+ func resignFirstResponderInSubtree()
32
+ }
33
+
22
34
  /**
23
35
  Protocol for wrapper views (e.g., UIBaseView) that wrap an inner view.
24
36
  Used by DynamicSwiftUIViewType to resolve the underlying view through wrapper layers.
@@ -123,7 +123,7 @@ extension ExpoSwiftUI {
123
123
  }
124
124
 
125
125
  override func removeFromSuperview() {
126
- virtualViewRemoveFromSuperview(contentView: contentView)
126
+ resignFirstResponderInSubtree()
127
127
  super.removeFromSuperview()
128
128
  }
129
129
 
@@ -146,6 +146,12 @@ extension ExpoSwiftUI {
146
146
  }
147
147
  }
148
148
 
149
+ extension ExpoSwiftUI.SwiftUIVirtualView: ExpoSwiftUI.FocusableViewContainer {
150
+ func resignFirstResponderInSubtree() {
151
+ virtualViewResignFirstResponderInSubtree(contentView: contentView, children: props.children)
152
+ }
153
+ }
154
+
149
155
  // MARK: - ViewWrapper (Production)
150
156
 
151
157
  extension ExpoSwiftUI.SwiftUIVirtualView: @MainActor ExpoSwiftUI.ViewWrapper {
@@ -283,7 +289,7 @@ extension ExpoSwiftUI {
283
289
  }
284
290
 
285
291
  override func removeFromSuperview() {
286
- virtualViewRemoveFromSuperview(contentView: contentView)
292
+ resignFirstResponderInSubtree()
287
293
  super.removeFromSuperview()
288
294
  }
289
295
 
@@ -306,6 +312,12 @@ extension ExpoSwiftUI {
306
312
  }
307
313
  }
308
314
 
315
+ extension ExpoSwiftUI.SwiftUIVirtualViewDev: ExpoSwiftUI.FocusableViewContainer {
316
+ func resignFirstResponderInSubtree() {
317
+ virtualViewResignFirstResponderInSubtree(contentView: contentView, children: props.children)
318
+ }
319
+ }
320
+
309
321
  // MARK: - ViewWrapper (Dev)
310
322
 
311
323
  extension ExpoSwiftUI.SwiftUIVirtualViewDev: @MainActor ExpoSwiftUI.ViewWrapper {
@@ -375,10 +387,14 @@ private func virtualViewUnmountChild<Props: ExpoSwiftUI.ViewProps>(_ childCompon
375
387
  }
376
388
  }
377
389
 
378
- private func virtualViewRemoveFromSuperview<ContentView: SwiftUI.View>(contentView: ContentView) {
379
- // When the view is unmounted, the focus on TextFieldView stays active and it causes a crash, so we blur it here
380
- // UIView does something similar to resign the first responder in removeFromSuperview, so we do the same for our virtual view
390
+ @MainActor
391
+ private func virtualViewResignFirstResponderInSubtree<ContentView: SwiftUI.View>(
392
+ contentView: ContentView, children: [any ExpoSwiftUI.AnyChild]?) {
393
+ // Mirror UIView.removeFromSuperview, which resigns the first responder for a view and its subviews;
394
+ // a field left first responder during SwiftUI's teardown crashes. Recurse into children so one
395
+ // nested in a container (HStack, LabeledContent, …) is resigned too. https://github.com/expo/expo/issues/47682
381
396
  if let focusableView = contentView as? any ExpoSwiftUI.FocusableView {
382
397
  focusableView.forceResignFirstResponder()
383
398
  }
399
+ children?.forEach { ($0 as? any ExpoSwiftUI.FocusableViewContainer)?.resignFirstResponderInSubtree() }
384
400
  }
@@ -4,24 +4,55 @@
4
4
 
5
5
  #ifdef __cplusplus
6
6
 
7
+ #include <memory>
8
+
9
+ namespace facebook::react {
10
+ class RuntimeScheduler;
11
+ } // namespace facebook::react
12
+
7
13
  namespace expo {
8
14
 
9
15
  /**
10
- Trampoline that `ExpoModulesJSI` calls to dispatch work onto the JS thread.
11
- Casts the `nativeScheduler` pointer back to a `react::RuntimeScheduler *` and
12
- calls `scheduleTask` on it. The signature matches `expo::RuntimeScheduler::ScheduleFn`
13
- declared in the xcframework's `RuntimeScheduler.h`.
16
+ Creates an opaque handle for `dispatchOnReactScheduler` that references the given React
17
+ runtime scheduler weakly. Returns `nullptr` when the scheduler is `nullptr` (e.g. no
18
+ `RuntimeSchedulerBinding` was installed).
19
+
20
+ The handle is never freed by design. Dispatch calls are bounded only by the lifetime of the
21
+ `JavaScriptRuntime` wrapper, which any escaped closure can extend past every point at which
22
+ the handle could be safely deleted. Each runtime creation therefore leaks the handle and,
23
+ because React Native allocates the scheduler with `std::make_shared`, the weak reference
24
+ also keeps the scheduler's storage from being reclaimed after its destruction. That cost
25
+ is small and bounded, unlike the use-after-free it prevents.
26
+ */
27
+ void *createReactSchedulerHandle(const std::shared_ptr<facebook::react::RuntimeScheduler> &scheduler);
28
+
29
+ /**
30
+ Trampoline that `ExpoModulesJSI` calls to dispatch work onto the JS thread. Locks the
31
+ scheduler weakly referenced by the handle (created with `createReactSchedulerHandle`) and
32
+ calls `scheduleTask` on it. The React instance owns the scheduler and destroys it on
33
+ teardown (e.g. reload) while native code may still dispatch through a retained
34
+ `JavaScriptRuntime`, so when the scheduler is gone (or the handle is `nullptr` because
35
+ no scheduler existed at creation) the task is dropped instead of dereferencing freed
36
+ memory. The signature matches `expo::RuntimeScheduler::ScheduleFn` declared in the
37
+ xcframework's `RuntimeScheduler.h`.
38
+
39
+ A narrow edge remains: when the lock races the React instance's final release, this
40
+ thread becomes the scheduler's last owner and runs its destructor once `scheduleTask`
41
+ returns, off the JS thread and potentially after the runtime is gone, destroying
42
+ still-queued tasks that may hold JSI values. React Native's own
43
+ `RuntimeSchedulerCallInvoker` locks the same weak reference to schedule work, so this
44
+ matches upstream behavior.
14
45
 
15
46
  Lives in ExpoModulesCore (rather than in the xcframework) so that
16
47
  ExpoModulesJSI.framework's prebuilt binary doesn't need to link against
17
- React-runtimescheduler important for source-built RN, where those symbols
48
+ React-runtimescheduler. That matters for source-built RN, where those symbols
18
49
  are hidden after link and unreachable via -undefined dynamic_lookup.
19
50
 
20
51
  Hosts that initialize their own runtime (e.g. ExpoReactNativeFactory, Expo Go)
21
52
  pass `&expo::dispatchOnReactScheduler` as the `dispatch` argument to
22
- `AppContext.setRuntime`.
53
+ `AppContext.setRuntime`, with the handle as the `scheduler` argument.
23
54
  */
24
- void dispatchOnReactScheduler(void *nativeScheduler, int priority, void (^callback)()) noexcept;
55
+ void dispatchOnReactScheduler(void *schedulerHandle, int priority, void (^callback)()) noexcept;
25
56
 
26
57
  } // namespace expo
27
58
 
@@ -6,9 +6,36 @@
6
6
 
7
7
  namespace expo {
8
8
 
9
- void dispatchOnReactScheduler(void *nativeScheduler, int priority, void (^callback)()) noexcept
9
+ namespace {
10
+
11
+ struct SchedulerHandle {
12
+ std::weak_ptr<facebook::react::RuntimeScheduler> scheduler;
13
+ };
14
+
15
+ } // namespace
16
+
17
+ void *createReactSchedulerHandle(const std::shared_ptr<facebook::react::RuntimeScheduler> &scheduler)
18
+ {
19
+ if (!scheduler) {
20
+ return nullptr;
21
+ }
22
+ return new SchedulerHandle{scheduler};
23
+ }
24
+
25
+ void dispatchOnReactScheduler(void *schedulerHandle, int priority, void (^callback)()) noexcept
10
26
  {
11
- auto *scheduler = static_cast<facebook::react::RuntimeScheduler *>(nativeScheduler);
27
+ auto *handle = static_cast<SchedulerHandle *>(schedulerHandle);
28
+ if (handle == nullptr) {
29
+ // `createReactSchedulerHandle` returns null when there was no scheduler to reference.
30
+ // Drop the task so callers don't have to guard the handle/dispatch pair themselves.
31
+ return;
32
+ }
33
+ // Locking either keeps the scheduler alive for the duration of `scheduleTask` or reports
34
+ // that the React instance already destroyed it, in which case the task is dropped.
35
+ auto scheduler = handle->scheduler.lock();
36
+ if (!scheduler) {
37
+ return;
38
+ }
12
39
  scheduler->scheduleTask(
13
40
  static_cast<facebook::react::SchedulerPriority>(priority),
14
41
  [callback](facebook::jsi::Runtime &) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-modules-core",
3
- "version": "57.0.3",
3
+ "version": "57.0.4",
4
4
  "description": "The core of Expo Modules architecture",
5
5
  "main": "src/index.ts",
6
6
  "types": "build/index.d.ts",
@@ -46,8 +46,8 @@
46
46
  "preset": "expo-module-scripts"
47
47
  },
48
48
  "dependencies": {
49
- "@expo/expo-modules-macros-plugin": "0.3.0",
50
- "expo-modules-jsi": "~57.0.1",
49
+ "@expo/expo-modules-macros-plugin": "0.6.1",
50
+ "expo-modules-jsi": "~57.0.2",
51
51
  "invariant": "^2.2.4"
52
52
  },
53
53
  "peerDependencies": {
@@ -66,7 +66,7 @@
66
66
  "@types/invariant": "^2.2.33",
67
67
  "expo-module-scripts": "56.0.3"
68
68
  },
69
- "gitHead": "b31bd70c8873eee2894bc5cc3b3460abca0cdea0",
69
+ "gitHead": "70af1caf83d8a324b46e02e18cd5c8c4e310da20",
70
70
  "scripts": {
71
71
  "build": "expo-module build",
72
72
  "clean": "expo-module clean",