expo-modules-core 56.0.20 → 56.0.22

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,24 @@
10
10
 
11
11
  ### 💡 Others
12
12
 
13
+ ## 56.0.22 — 2026-07-23
14
+
15
+ ### 🎉 New features
16
+
17
+ - [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))
18
+
19
+ ### 🐛 Bug fixes
20
+
21
+ - [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))
22
+ - [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))
23
+
24
+ ## 56.0.21 — 2026-07-15
25
+
26
+ ### 🐛 Bug fixes
27
+
28
+ - [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))
29
+ - [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))
30
+
13
31
  ## 56.0.20 — 2026-07-07
14
32
 
15
33
  _This version does not introduce any user-facing changes._
@@ -27,7 +27,7 @@ if (shouldIncludeCompose) {
27
27
  }
28
28
 
29
29
  group = 'host.exp.exponent'
30
- version = '56.0.20'
30
+ version = '56.0.22'
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 "56.0.20"
97
+ versionName "56.0.22"
98
98
  buildConfigField "String", "EXPO_MODULES_CORE_VERSION", "\"${versionName}\""
99
99
  buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", "true"
100
100
 
@@ -0,0 +1,148 @@
1
+ package expo.modules.kotlin.types
2
+
3
+ import android.graphics.Color
4
+ import android.os.Build
5
+ import androidx.annotation.ColorInt
6
+ import androidx.annotation.RequiresApi
7
+
8
+ /**
9
+ * Compatibility wrapper for [Color] instance APIs added in API 26.
10
+ *
11
+ * Remove this class when expo-modules-core minSdkVersion is 26 or higher. Android Lint's
12
+ * ObsoleteSdkInt check should flag these version gates after that bump.
13
+ */
14
+ class ColorCompat private constructor() {
15
+ companion object {
16
+ private val defaultLegacyColor = LegacyColor(
17
+ red = 0f,
18
+ green = 0f,
19
+ blue = 0f,
20
+ alpha = 1f,
21
+ argb = Color.BLACK
22
+ )
23
+
24
+ @JvmStatic
25
+ fun valueOf(@ColorInt color: Int): Color {
26
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
27
+ return Api26Impl.valueOf(color)
28
+ }
29
+ return LegacyColor(
30
+ red = Color.red(color) / 255f,
31
+ green = Color.green(color) / 255f,
32
+ blue = Color.blue(color) / 255f,
33
+ alpha = Color.alpha(color) / 255f,
34
+ argb = color
35
+ )
36
+ }
37
+
38
+ @JvmStatic
39
+ fun valueOf(red: Float, green: Float, blue: Float, alpha: Float): Color {
40
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
41
+ return Api26Impl.valueOf(red, green, blue, alpha)
42
+ }
43
+ val saturatedRed = red.coerceIn(0f, 1f)
44
+ val saturatedGreen = green.coerceIn(0f, 1f)
45
+ val saturatedBlue = blue.coerceIn(0f, 1f)
46
+ val saturatedAlpha = alpha.coerceIn(0f, 1f)
47
+ return LegacyColor(
48
+ red = saturatedRed,
49
+ green = saturatedGreen,
50
+ blue = saturatedBlue,
51
+ alpha = saturatedAlpha,
52
+ argb = Color.argb(
53
+ toColorComponent(saturatedAlpha),
54
+ toColorComponent(saturatedRed),
55
+ toColorComponent(saturatedGreen),
56
+ toColorComponent(saturatedBlue)
57
+ )
58
+ )
59
+ }
60
+
61
+ @JvmStatic
62
+ fun toArgb(color: Color): Int {
63
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
64
+ return Api26Impl.toArgb(color)
65
+ }
66
+ return legacyColor(color).argb
67
+ }
68
+
69
+ @JvmStatic
70
+ fun alpha(color: Color): Float {
71
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
72
+ return Api26Impl.alpha(color)
73
+ }
74
+ return legacyColor(color).alpha
75
+ }
76
+
77
+ @JvmStatic
78
+ fun red(color: Color): Float {
79
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
80
+ return Api26Impl.red(color)
81
+ }
82
+ return legacyColor(color).red
83
+ }
84
+
85
+ @JvmStatic
86
+ fun green(color: Color): Float {
87
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
88
+ return Api26Impl.green(color)
89
+ }
90
+ return legacyColor(color).green
91
+ }
92
+
93
+ @JvmStatic
94
+ fun blue(color: Color): Float {
95
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
96
+ return Api26Impl.blue(color)
97
+ }
98
+ return legacyColor(color).blue
99
+ }
100
+
101
+ private fun legacyColor(color: Color): LegacyColor {
102
+ return color as? LegacyColor ?: defaultLegacyColor
103
+ }
104
+
105
+ private fun toColorComponent(component: Float): Int {
106
+ return (component * 255f + 0.5f).toInt()
107
+ }
108
+ }
109
+
110
+ private class LegacyColor(
111
+ val red: Float,
112
+ val green: Float,
113
+ val blue: Float,
114
+ val alpha: Float,
115
+ @ColorInt val argb: Int
116
+ ) : Color()
117
+
118
+ @RequiresApi(Build.VERSION_CODES.O)
119
+ private object Api26Impl {
120
+ fun valueOf(@ColorInt color: Int): Color {
121
+ return Color.valueOf(color)
122
+ }
123
+
124
+ fun valueOf(red: Float, green: Float, blue: Float, alpha: Float): Color {
125
+ return Color.valueOf(red, green, blue, alpha)
126
+ }
127
+
128
+ fun toArgb(color: Color): Int {
129
+ return color.toArgb()
130
+ }
131
+
132
+ fun alpha(color: Color): Float {
133
+ return color.alpha()
134
+ }
135
+
136
+ fun red(color: Color): Float {
137
+ return color.red()
138
+ }
139
+
140
+ fun green(color: Color): Float {
141
+ return color.green()
142
+ }
143
+
144
+ fun blue(color: Color): Float {
145
+ return color.blue()
146
+ }
147
+ }
148
+ }
@@ -1,8 +1,6 @@
1
1
  package expo.modules.kotlin.types
2
2
 
3
3
  import android.graphics.Color
4
- import android.os.Build
5
- import androidx.annotation.RequiresApi
6
4
  import com.facebook.react.bridge.Dynamic
7
5
  import com.facebook.react.bridge.ReadableType
8
6
  import expo.modules.kotlin.AppContext
@@ -262,7 +260,7 @@ private fun hslToColor(h: Float, s: Float, l: Float, a: Float): Color {
262
260
  g = hueToRgb(p, q, hue)
263
261
  b = hueToRgb(p, q, hue - 1f / 3f)
264
262
  }
265
- return Color.valueOf(r.coerceIn(0f, 1f), g.coerceIn(0f, 1f), b.coerceIn(0f, 1f), a)
263
+ return ColorCompat.valueOf(r.coerceIn(0f, 1f), g.coerceIn(0f, 1f), b.coerceIn(0f, 1f), a)
266
264
  }
267
265
 
268
266
  private fun hwbToColor(h: Float, w: Float, b: Float, a: Float): Color {
@@ -272,10 +270,10 @@ private fun hwbToColor(h: Float, w: Float, b: Float, a: Float): Color {
272
270
  val white = if (sum > 1f) ww / sum else ww
273
271
  val black = if (sum > 1f) bb / sum else bb
274
272
  val rgb = hslToColor(h, 1f, 0.5f, 1f)
275
- val r = rgb.red() * (1f - white - black) + white
276
- val g = rgb.green() * (1f - white - black) + white
277
- val bl = rgb.blue() * (1f - white - black) + white
278
- return Color.valueOf(r.coerceIn(0f, 1f), g.coerceIn(0f, 1f), bl.coerceIn(0f, 1f), a)
273
+ val r = ColorCompat.red(rgb) * (1f - white - black) + white
274
+ val g = ColorCompat.green(rgb) * (1f - white - black) + white
275
+ val bl = ColorCompat.blue(rgb) * (1f - white - black) + white
276
+ return ColorCompat.valueOf(r.coerceIn(0f, 1f), g.coerceIn(0f, 1f), bl.coerceIn(0f, 1f), a)
279
277
  }
280
278
 
281
279
  /**
@@ -292,7 +290,7 @@ private fun parseHexColor(value: String): Color? {
292
290
  val r = match.groupValues[1].repeat(2).toInt(16)
293
291
  val g = match.groupValues[2].repeat(2).toInt(16)
294
292
  val b = match.groupValues[3].repeat(2).toInt(16)
295
- return Color.valueOf(r / 255f, g / 255f, b / 255f, 1f)
293
+ return ColorCompat.valueOf(r / 255f, g / 255f, b / 255f, 1f)
296
294
  }
297
295
 
298
296
  // #RGBA → #RRGGBBAA
@@ -301,7 +299,7 @@ private fun parseHexColor(value: String): Color? {
301
299
  val g = match.groupValues[2].repeat(2).toInt(16)
302
300
  val b = match.groupValues[3].repeat(2).toInt(16)
303
301
  val a = match.groupValues[4].repeat(2).toInt(16)
304
- return Color.valueOf(r / 255f, g / 255f, b / 255f, a / 255f)
302
+ return ColorCompat.valueOf(r / 255f, g / 255f, b / 255f, a / 255f)
305
303
  }
306
304
 
307
305
  // #RRGGBBAA (CSS byte order: alpha is last, unlike Android's #AARRGGBB)
@@ -311,7 +309,7 @@ private fun parseHexColor(value: String): Color? {
311
309
  val g = ((hex shr 16) and 0xFF).toInt()
312
310
  val b = ((hex shr 8) and 0xFF).toInt()
313
311
  val a = (hex and 0xFF).toInt()
314
- return Color.valueOf(r / 255f, g / 255f, b / 255f, a / 255f)
312
+ return ColorCompat.valueOf(r / 255f, g / 255f, b / 255f, a / 255f)
315
313
  }
316
314
 
317
315
  return null
@@ -329,7 +327,7 @@ private fun parseCssColorFunction(value: String): Color? {
329
327
  val g = parseRgbComponent(match.groupValues[2])
330
328
  val b = parseRgbComponent(match.groupValues[3])
331
329
  val a = parseAlpha(match.groupValues[4].ifEmpty { null })
332
- return Color.valueOf(r, g, b, a)
330
+ return ColorCompat.valueOf(r, g, b, a)
333
331
  }
334
332
 
335
333
  // hsl/hsla
@@ -355,7 +353,6 @@ private fun parseCssColorFunction(value: String): Color? {
355
353
 
356
354
  // endregion
357
355
 
358
- @RequiresApi(Build.VERSION_CODES.O)
359
356
  class ColorTypeConverter : DynamicAwareTypeConverters<Color>() {
360
357
  override fun convertFromDynamic(value: Dynamic, context: AppContext?, forceConversion: Boolean): Color {
361
358
  return when (value.type) {
@@ -389,18 +386,18 @@ class ColorTypeConverter : DynamicAwareTypeConverters<Color>() {
389
386
  throw InvalidColorComponentsException(value.size)
390
387
  }
391
388
  val alpha = value.getOrNull(3) ?: 1.0
392
- return Color.valueOf(value[0].toFloat(), value[1].toFloat(), value[2].toFloat(), alpha.toFloat())
389
+ return ColorCompat.valueOf(value[0].toFloat(), value[1].toFloat(), value[2].toFloat(), alpha.toFloat())
393
390
  }
394
391
 
395
392
  private fun colorFromInt(value: Int): Color {
396
- return Color.valueOf(value)
393
+ return ColorCompat.valueOf(value)
397
394
  }
398
395
 
399
396
  private fun colorFromString(value: String): Color {
400
397
  val normalizedValue = value.trim().lowercase()
401
398
  val colorFromString = namedColors[normalizedValue]
402
399
  if (colorFromString != null) {
403
- return Color.valueOf(
400
+ return ColorCompat.valueOf(
404
401
  colorFromString[0],
405
402
  colorFromString[1],
406
403
  colorFromString[2],
@@ -411,12 +408,12 @@ class ColorTypeConverter : DynamicAwareTypeConverters<Color>() {
411
408
  if (normalizedValue.startsWith('#')) {
412
409
  parseHexColor(normalizedValue)?.let { return it }
413
410
  // Fall through to toColorInt() for standard #RRGGBB / #AARRGGBB
414
- return Color.valueOf(normalizedValue.toColorInt())
411
+ return ColorCompat.valueOf(normalizedValue.toColorInt())
415
412
  }
416
413
 
417
414
  parseCssColorFunction(normalizedValue)?.let { return it }
418
415
 
419
- return Color.valueOf(normalizedValue.toColorInt())
416
+ return ColorCompat.valueOf(normalizedValue.toColorInt())
420
417
  }
421
418
 
422
419
  override fun getCppRequiredTypes(): ExpectedType =
@@ -275,6 +275,8 @@ object TypeConverterProviderImpl : TypeConverterProvider {
275
275
 
276
276
  Any::class.java to AnyTypeConverter(),
277
277
 
278
+ Color::class.java to ColorTypeConverter(),
279
+
278
280
  // Unit converter doesn't care about nullability.
279
281
  // It will always return Unit
280
282
  Unit::class.java to UnitTypeConverter(),
@@ -285,7 +287,6 @@ object TypeConverterProviderImpl : TypeConverterProvider {
285
287
  if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
286
288
  return converters + mapOf(
287
289
  Path::class.java to PathTypeConverter(),
288
- Color::class.java to ColorTypeConverter(),
289
290
  LocalDate::class.java to DateTypeConverter()
290
291
  )
291
292
  }
@@ -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
  }
@@ -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.
@@ -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
 
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-modules-core",
3
- "version": "56.0.20",
3
+ "version": "56.0.22",
4
4
  "description": "The core of Expo Modules architecture",
5
5
  "main": "src/index.ts",
6
6
  "types": "build/index.d.ts",
@@ -66,7 +66,7 @@
66
66
  "@types/invariant": "^2.2.33",
67
67
  "expo-module-scripts": "56.0.3"
68
68
  },
69
- "gitHead": "f7fcf668bc1a31cccd73275e6c316a0d1253f4c9",
69
+ "gitHead": "60c9da71e15060aa859a7a6e85f4b14062b761f4",
70
70
  "scripts": {
71
71
  "build": "expo-module build",
72
72
  "clean": "expo-module clean",