expo-modules-core 56.0.19 → 56.0.21

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,17 @@
10
10
 
11
11
  ### 💡 Others
12
12
 
13
+ ## 56.0.21 — 2026-07-15
14
+
15
+ ### 🐛 Bug fixes
16
+
17
+ - [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))
18
+ - [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))
19
+
20
+ ## 56.0.20 — 2026-07-07
21
+
22
+ _This version does not introduce any user-facing changes._
23
+
13
24
  ## 56.0.19 — 2026-07-03
14
25
 
15
26
  ### 🐛 Bug fixes
@@ -27,7 +27,7 @@ if (shouldIncludeCompose) {
27
27
  }
28
28
 
29
29
  group = 'host.exp.exponent'
30
- version = '56.0.19'
30
+ version = '56.0.21'
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.19"
97
+ versionName "56.0.21"
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
  }
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-modules-core",
3
- "version": "56.0.19",
3
+ "version": "56.0.21",
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.2.2",
50
- "expo-modules-jsi": "~56.0.11",
50
+ "expo-modules-jsi": "~56.0.12",
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": "b2e161a54f90a778ab7e5560c0c8f021bbfcaae2",
69
+ "gitHead": "8a480d22f04f94fd36570a319baeaa3529330794",
70
70
  "scripts": {
71
71
  "build": "expo-module build",
72
72
  "clean": "expo-module clean",