expo-modules-core 57.0.5 → 57.0.7

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.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,20 @@
10
10
 
11
11
  ### 💡 Others
12
12
 
13
+ ## 57.0.7 — 2026-07-22
14
+
15
+ _This version does not introduce any user-facing changes._
16
+
17
+ ## 57.0.6 — 2026-07-17
18
+
19
+ ### 🎉 New features
20
+
21
+ - [iOS] `ExpoSwiftUI.IgnoreSafeArea` that ignores the device and container safe area regions. ([#47619](https://github.com/expo/expo/pull/47619) by [@nishan](https://github.com/intergalacticspacehighway))
22
+
23
+ ### 🐛 Bug fixes
24
+
25
+ - [Android] Fix `canAskAgain` returning `false` for re-requestable permissions in the "Ask every time" state. ([#46683](https://github.com/expo/expo/pull/46683) by [@alanjhughes](https://github.com/alanjhughes))
26
+
13
27
  ## 57.0.5 — 2026-07-15
14
28
 
15
29
  _This version does not introduce any user-facing changes._
@@ -22,6 +36,8 @@ _This version does not introduce any user-facing changes._
22
36
 
23
37
  ### 🐛 Bug fixes
24
38
 
39
+ - [iOS] Fix SwiftUI-hosted React views corrupting Fabric rendering after unmount: `UIViewHost` now hands SwiftUI a disposable isolation container instead of the React-managed view, so leaked `autoresizingMask`/frame/visibility mutations (including deferred ones from `Menu`/`ContextMenu` teardown) can no longer zero-size or hide unrelated components. ([#47707](https://github.com/expo/expo/pull/47707) by [@cvburgess](https://github.com/cvburgess))
40
+ - [Android] Fix Compose-hosted React Native content (e.g. the `@expo/ui` community `BottomSheet`) keeping a stale size after a rapid resize such as dismissing the software keyboard, leaving bottom-anchored content stranded mid-view. The final shadow node size update could be dropped when its one-shot pre-draw listener fired without a following draw pass; the pending update is now also posted to the view so the latest size always flushes. ([#47778](https://github.com/expo/expo/issues/47778) by [@zayyartun-cgm](https://github.com/zayyartun-cgm)) ([#47810](https://github.com/expo/expo/pull/47810) by [@intergalacticspacehighway](https://github.com/intergalacticspacehighway))
25
41
  - [iOS] Fixed a crash when the runtime is reloaded while async function calls are still suspended in their native body. Concurrent functions now decode `this` and the arguments buffer synchronously inside the host call and schedule only converted native values, so no JSI-owned wrappers are destroyed off the JS thread after the runtime is torn down. ([#47716](https://github.com/expo/expo/issues/47716), [#47717](https://github.com/expo/expo/pull/47717) by [@AlirezaHadjar](https://github.com/AlirezaHadjar))
26
42
  - [Android] Support `android.graphics.Color` arguments and props on Android below API 26 by adding a `ColorCompat` wrapper, since the class-based `Color` API (`Color.valueOf` and the float accessors) only exists on API 26 and above. ([#47546](https://github.com/expo/expo/issues/47546) by [@anasvemmully](https://github.com/anasvemmully)) ([#47575](https://github.com/expo/expo/pull/47575) by [@intergalacticspacehighway](https://github.com/intergalacticspacehighway))
27
43
  - [iOS] Fix a `SIGABRT` crash when unmounting a SwiftUI view tree that still holds a focused field nested inside a container (e.g. an `@expo/ui` `TextField` inside an `HStack` or `LabeledContent` in a `Form`), such as dismissing a modal from a native header button. Resigning the first responder on unmount now recurses through the removed subtree instead of only handling a top-level focusable view. ([#47682](https://github.com/expo/expo/issues/47682) by [@andreavrr](https://github.com/andreavrr)) ([#47709](https://github.com/expo/expo/pull/47709) by [@intergalacticspacehighway](https://github.com/intergalacticspacehighway))
@@ -27,7 +27,7 @@ if (shouldIncludeCompose) {
27
27
  }
28
28
 
29
29
  group = 'host.exp.exponent'
30
- version = '57.0.5'
30
+ version = '57.0.7'
31
31
 
32
32
  def isExpoModulesCoreTests = {
33
33
  Gradle gradle = getGradle()
@@ -94,7 +94,7 @@ android {
94
94
  defaultConfig {
95
95
  consumerProguardFiles 'proguard-rules.pro'
96
96
  versionCode 1
97
- versionName "57.0.5"
97
+ versionName "57.0.7"
98
98
  buildConfigField "String", "EXPO_MODULES_CORE_VERSION", "\"${versionName}\""
99
99
  buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", "true"
100
100
 
@@ -27,6 +27,7 @@ import java.util.Queue
27
27
 
28
28
  private const val PERMISSIONS_REQUEST: Int = 13
29
29
  private const val PREFERENCE_FILENAME = "expo.modules.permissions.asked"
30
+ private const val BLOCKED_PERMISSION_KEY_PREFIX = "blocked:"
30
31
 
31
32
  open class PermissionsService(val context: Context) : InternalModule, Permissions, LifecycleEventListener {
32
33
  private var mActivityProvider: ActivityProvider? = null
@@ -49,6 +50,42 @@ open class PermissionsService(val context: Context) : InternalModule, Permission
49
50
  }
50
51
  }
51
52
 
53
+ /**
54
+ * Android can't tell a permanently denied permission apart from a re-askable one ("Ask every time", or a revoked
55
+ * one-time grant) at query time, so we record it ourselves.
56
+ */
57
+ private fun isBlocked(permission: String): Boolean =
58
+ mAskedPermissionsCache.getBoolean(blockedPermissionKey(permission), false)
59
+
60
+ private fun setBlocked(permission: String, blocked: Boolean) {
61
+ mAskedPermissionsCache.edit(commit = true) {
62
+ putBoolean(blockedPermissionKey(permission), blocked)
63
+ }
64
+ }
65
+
66
+ private fun blockedPermissionKey(permission: String): String = "$BLOCKED_PERMISSION_KEY_PREFIX$permission"
67
+
68
+ /**
69
+ * Record whether each permission became permanently denied. A denied result with no rationale to show means the user blocked it.
70
+ * Anything else clears the flag. This is the only time the denied states are distinguishable,
71
+ * so we persist the result for later ``getPermissions` calls.
72
+ */
73
+ private fun withBlockedTracking(listener: PermissionsResponseListener): PermissionsResponseListener =
74
+ PermissionsResponseListener { result ->
75
+ listener.onResult(
76
+ result.mapValues { (permission, response) ->
77
+ if (response.status == PermissionsStatus.DENIED) {
78
+ val blocked = !shouldShowRequestPermissionRationale(permission)
79
+ setBlocked(permission, blocked)
80
+ PermissionsResponse(response.status, !blocked)
81
+ } else {
82
+ setBlocked(permission, false)
83
+ response
84
+ }
85
+ }
86
+ )
87
+ }
88
+
52
89
  override fun getExportedInterfaces(): List<Class<out Any>> = listOf(Permissions::class.java)
53
90
 
54
91
  @Throws(IllegalStateException::class)
@@ -155,10 +192,10 @@ open class PermissionsService(val context: Context) : InternalModule, Permission
155
192
  newListener.onResult(mutableMapOf())
156
193
  return
157
194
  }
158
- askForManifestPermissions(permissionsToAsk, newListener)
195
+ askForManifestPermissions(permissionsToAsk, withBlockedTracking(newListener))
159
196
  }
160
197
  } else {
161
- askForManifestPermissions(permissions, responseListener)
198
+ askForManifestPermissions(permissions, withBlockedTracking(responseListener))
162
199
  }
163
200
  }
164
201
 
@@ -213,7 +250,7 @@ open class PermissionsService(val context: Context) : InternalModule, Permission
213
250
  return ContextCompat.checkSelfPermission(context, permission)
214
251
  }
215
252
 
216
- private fun canAskAgain(permission: String): Boolean {
253
+ protected open fun shouldShowRequestPermissionRationale(permission: String): Boolean {
217
254
  return mActivityProvider?.currentActivity?.let {
218
255
  ActivityCompat.shouldShowRequestPermissionRationale(it, permission)
219
256
  } == true
@@ -236,7 +273,7 @@ open class PermissionsService(val context: Context) : InternalModule, Permission
236
273
  return PermissionsResponse(
237
274
  status,
238
275
  if (status == PermissionsStatus.DENIED) {
239
- canAskAgain(permission)
276
+ !isBlocked(permission)
240
277
  } else {
241
278
  true
242
279
  }
@@ -10,6 +10,7 @@ class ShadowNodeProxy(expoView: ExpoView) {
10
10
 
11
11
  private var pendingFlush: ((stateWrapper: Any) -> Unit)? = null
12
12
  private var preDrawListener: ViewTreeObserver.OnPreDrawListener? = null
13
+ private val flushRunnable = Runnable { drainPendingFlush() }
13
14
 
14
15
  // Schedule in predraw listener to avoid early return in re-entrancy
15
16
  // We have a proper fix [here](https://github.com/facebook/react-native/pull/56311)
@@ -29,25 +30,37 @@ class ShadowNodeProxy(expoView: ExpoView) {
29
30
 
30
31
  private fun scheduleFlush(flush: (stateWrapper: Any) -> Unit) {
31
32
  pendingFlush = flush
32
- val observer = weakExpoView.get()?.viewTreeObserver?.takeIf { it.isAlive } ?: return
33
-
34
- // Remove the previous attached listener
35
- preDrawListener?.let(observer::removeOnPreDrawListener)
36
-
37
- val listener = object : ViewTreeObserver.OnPreDrawListener {
38
- override fun onPreDraw(): Boolean {
39
- preDrawListener = null
40
- // The view is attached while drawing, so this re-fetch returns the same
41
- // observer that is dispatching us. removeOnPreDrawListener throws on a dead
42
- // observer, hence the isAlive guard.
43
- weakExpoView.get()?.viewTreeObserver?.takeIf { it.isAlive }?.removeOnPreDrawListener(this)
44
- val flushNow = pendingFlush
45
- pendingFlush = null
46
- weakExpoView.get()?.stateWrapper?.let { flushNow?.invoke(it) }
47
- return true
33
+ val view = weakExpoView.get() ?: return
34
+ val observer = view.viewTreeObserver?.takeIf { it.isAlive }
35
+
36
+ if (observer != null) {
37
+ // Remove the previous attached listener
38
+ preDrawListener?.let(observer::removeOnPreDrawListener)
39
+
40
+ val listener = object : ViewTreeObserver.OnPreDrawListener {
41
+ override fun onPreDraw(): Boolean {
42
+ preDrawListener = null
43
+ // The view is attached while drawing, so this re-fetch returns the same
44
+ // observer that is dispatching us. removeOnPreDrawListener throws on a dead
45
+ // observer, hence the isAlive guard.
46
+ weakExpoView.get()?.viewTreeObserver?.takeIf { it.isAlive }?.removeOnPreDrawListener(this)
47
+ drainPendingFlush()
48
+ return true
49
+ }
48
50
  }
51
+ preDrawListener = listener
52
+ observer.addOnPreDrawListener(listener)
49
53
  }
50
- preDrawListener = listener
51
- observer.addOnPreDrawListener(listener)
54
+
55
+ // Predraw listener do not get called for each keyboard transition event so we add a fallback flush to be called here
56
+ // https://github.com/expo/expo/issues/47778
57
+ view.removeCallbacks(flushRunnable)
58
+ view.post(flushRunnable)
59
+ }
60
+
61
+ private fun drainPendingFlush() {
62
+ val flushNow = pendingFlush ?: return
63
+ pendingFlush = null
64
+ weakExpoView.get()?.stateWrapper?.let { flushNow.invoke(it) }
52
65
  }
53
66
  }
@@ -60,11 +60,6 @@ extension ExpoSwiftUI {
60
60
  */
61
61
  private let hostingController: UIHostingController<AnyView>
62
62
 
63
- /**
64
- Tracks whether safe area has been configured (can only be set once on mount)
65
- */
66
- private var hasSafeAreaBeenConfigured = false
67
-
68
63
  /**
69
64
  Initializes a SwiftUI hosting view with the given SwiftUI view type.
70
65
  */
@@ -121,11 +116,8 @@ extension ExpoSwiftUI {
121
116
  log.error("Updating props for \(ContentView.self) has failed: \(error.localizedDescription)")
122
117
  }
123
118
 
124
- if !hasSafeAreaBeenConfigured,
125
- let safeAreaProps = props as? SafeAreaControllable,
126
- let ignoreSafeArea = safeAreaProps.ignoreSafeArea {
127
- hostingController.disableSafeArea(ignoreSafeArea)
128
- hasSafeAreaBeenConfigured = true
119
+ if let safeAreaProps = props as? SafeAreaControllable {
120
+ hostingController.setSafeAreaRegions(ignoring: safeAreaProps.ignoreSafeArea)
129
121
  }
130
122
  }
131
123
 
@@ -260,45 +252,26 @@ extension ExpoSwiftUI {
260
252
  }
261
253
 
262
254
  extension UIHostingController {
263
- func disableSafeArea(_ mode: ExpoSwiftUI.IgnoreSafeArea) {
264
- if #available(iOS 16.4, tvOS 16.4, macOS 13.3, *) {
255
+ /// Applies the `ignoreSafeArea` mode reactively, restoring the default safe area when `nil` so
256
+ /// clearing the prop re-enables the safe area without an app reload.
257
+ func setSafeAreaRegions(ignoring mode: ExpoSwiftUI.IgnoreSafeArea?) {
258
+ // `safeAreaRegions` needs iOS 16.4+; the precompiled xcframework targets 16.0, so no-op below it.
259
+ guard #available(iOS 16.4, tvOS 16.4, macOS 13.3, *) else {
260
+ return
261
+ }
262
+ var regions: SafeAreaRegions = .all
263
+ if let mode {
265
264
  switch mode {
266
265
  case .all:
267
- self.safeAreaRegions.remove(.all)
266
+ regions = []
267
+ case .container:
268
+ regions.remove(.container)
268
269
  case .keyboard:
269
- self.safeAreaRegions.remove(.keyboard)
270
- }
271
- } else {
272
- // For older versions
273
- // https://gist.github.com/steipete/da72299613dcc91e8d729e48b4bb582c
274
- // https://developer.apple.com/forums/thread/658432
275
- guard let viewClass = object_getClass(view) else { return }
276
-
277
- let suffix = mode == .all ? "_IgnoresSafeArea" : "_IgnoresKeyboard"
278
- let viewSubclassName = String(cString: class_getName(viewClass)).appending(suffix)
279
- if let viewSubclass = NSClassFromString(viewSubclassName) {
280
- object_setClass(view, viewSubclass)
281
- } else {
282
- guard let viewClassNameUtf8 = (viewSubclassName as NSString).utf8String else { return }
283
- guard let viewSubclass = objc_allocateClassPair(viewClass, viewClassNameUtf8, 0) else { return }
284
-
285
- if mode == .all,
286
- let method = class_getInstanceMethod(UIView.self, #selector(getter: UIView.safeAreaInsets)) {
287
- let safeAreaInsets: @convention(block) (AnyObject) -> UIEdgeInsets = { _ in
288
- return .zero
289
- }
290
- class_addMethod(viewSubclass, #selector(getter: UIView.safeAreaInsets),
291
- imp_implementationWithBlock(safeAreaInsets), method_getTypeEncoding(method))
292
- }
293
-
294
- if let method = class_getInstanceMethod(viewClass, NSSelectorFromString("keyboardWillShowWithNotification:")) {
295
- let keyboardWillShow: @convention(block) (AnyObject, AnyObject) -> Void = { _, _ in }
296
- class_addMethod(viewSubclass, NSSelectorFromString("keyboardWillShowWithNotification:"),
297
- imp_implementationWithBlock(keyboardWillShow), method_getTypeEncoding(method))
298
- }
299
- objc_registerClassPair(viewSubclass)
300
- object_setClass(view, viewSubclass)
301
- }
270
+ regions.remove(.keyboard)
302
271
  }
303
272
  }
273
+ if safeAreaRegions != regions {
274
+ safeAreaRegions = regions
275
+ }
276
+ }
304
277
  }
@@ -13,7 +13,6 @@ extension ExpoSwiftUI {
13
13
 
14
14
  #if os(macOS)
15
15
  func makeNSView(context: Context) -> NSView {
16
- context.coordinator.originalAutoresizingMask = view.autoresizingMask
17
16
  return view
18
17
  }
19
18
 
@@ -23,8 +22,17 @@ extension ExpoSwiftUI {
23
22
  #endif
24
23
 
25
24
  func makeUIView(context: Context) -> UIView {
26
- context.coordinator.originalAutoresizingMask = view.autoresizingMask
25
+ #if os(macOS)
27
26
  return view
27
+ #else
28
+ // SwiftUI mutates the view it hosts (autoresizingMask, frame, visibility), sometimes
29
+ // asynchronously after teardown, corrupting unmounted or recycled React views.
30
+ // Hand it a disposable container instead. Fixes expo/expo#47706; supersedes the #40604 mitigation.
31
+ let container = ReactViewIsolationContainer()
32
+ container.hostedView = view
33
+ container.addSubview(view)
34
+ return container
35
+ #endif
28
36
  }
29
37
 
30
38
  func updateUIView(_ uiView: UIView, context: Context) {
@@ -32,11 +40,7 @@ extension ExpoSwiftUI {
32
40
  }
33
41
 
34
42
  static func dismantleUIView(_ uiView: UIView, coordinator: Coordinator) {
35
- // https://github.com/expo/expo/issues/40604
36
- // UIViewRepresentable attaches autoresizingMask w+h to the hosted UIView
37
- // This causes issues for RN views when they are recycled.
38
- // So we restore the original autoresizingMask to avoid issues.
39
- uiView.autoresizingMask = coordinator.originalAutoresizingMask
43
+ // Nothing to restore — SwiftUI only ever touched the container.
40
44
  }
41
45
 
42
46
  func makeCoordinator() -> Coordinator {
@@ -44,7 +48,6 @@ extension ExpoSwiftUI {
44
48
  }
45
49
 
46
50
  class Coordinator {
47
- var originalAutoresizingMask: UIView.AutoresizingMask = []
48
51
  init() {}
49
52
  }
50
53
 
@@ -63,5 +66,24 @@ extension ExpoSwiftUI {
63
66
  }
64
67
  }
65
68
 
69
+ #if !os(macOS)
70
+ /**
71
+ Disposable UIView handed to SwiftUI in place of the React-managed view —
72
+ see `UIViewHost.makeUIView`.
73
+ */
74
+ private final class ReactViewIsolationContainer: UIView {
75
+ weak var hostedView: UIView?
76
+
77
+ override func layoutSubviews() {
78
+ super.layoutSubviews()
79
+ // Ignore transient zero bounds (initial layout, teardown) — never propagate them.
80
+ guard let hostedView, hostedView.superview === self, !bounds.isEmpty else {
81
+ return
82
+ }
83
+ if hostedView.frame != bounds {
84
+ hostedView.frame = bounds
85
+ }
86
+ }
87
+ }
88
+ #endif
66
89
  }
67
-
@@ -7,6 +7,7 @@ internal let GLOBAL_EVENT_NAME = "onGlobalEvent"
7
7
  extension ExpoSwiftUI {
8
8
  public enum IgnoreSafeArea: String, Enumerable {
9
9
  case all
10
+ case container
10
11
  case keyboard
11
12
  }
12
13
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-modules-core",
3
- "version": "57.0.5",
3
+ "version": "57.0.7",
4
4
  "description": "The core of Expo Modules architecture",
5
5
  "main": "src/index.ts",
6
6
  "types": "build/index.d.ts",
@@ -47,7 +47,7 @@
47
47
  },
48
48
  "dependencies": {
49
49
  "@expo/expo-modules-macros-plugin": "0.6.1",
50
- "expo-modules-jsi": "~57.0.3",
50
+ "expo-modules-jsi": "~57.0.4",
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": "b9503d06b8e130abfca1bc16f671dbefb41ad709",
69
+ "gitHead": "a4789f1e53353f4929b0baddcfe5a7c622b99c71",
70
70
  "scripts": {
71
71
  "build": "expo-module build",
72
72
  "clean": "expo-module clean",