expo-modules-core 56.0.16 → 56.0.17

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,12 @@
10
10
 
11
11
  ### 💡 Others
12
12
 
13
+ ## 56.0.17 — 2026-06-15
14
+
15
+ ### 🐛 Bug fixes
16
+
17
+ - [Android] Fixed Expo UI re-compose when switching screens in react-native-screens. ([#46650](https://github.com/expo/expo/pull/46650) by [@kudo](https://github.com/kudo))
18
+
13
19
  ## 56.0.16 — 2026-06-10
14
20
 
15
21
  ### 🎉 New features
@@ -19,6 +25,7 @@
19
25
  ### 🐛 Bug fixes
20
26
 
21
27
  - [android] Add a synchronous shadow node size update path, fixing a layout shift for `Host` `matchContents` views. ([#46604](https://github.com/expo/expo/pull/46604) by [@nishan](https://github.com/intergalacticspacehighway))
28
+ - [Android] Fix `NullPointerException` crash when a `matchContents` view is unmounted while a shadow node size update is pending (e.g. closing a bottom sheet mid-resize). ([#46785](https://github.com/expo/expo/pull/46785) by [@nishan](https://github.com/intergalacticspacehighway))
22
29
  - [iOS] Accept JS `Double` timestamps in `Date` Convertible. JS numbers arrive across the JSI bridge as Swift `Double`; the prior `as? Int` branch never matched, throwing `ConvertingException<Date>` whenever a JS caller passed `someDate.getTime()` to a `Date` / `Date?` argument. ([#46340](https://github.com/expo/expo/pull/46340) by [@kyleasaff](https://github.com/kyleasaff))
23
30
  - [Android] Fix events being silently dropped for Compose views in custom modules. ([#46623](https://github.com/expo/expo/issues/46623) by [@benjaminkomen](https://github.com/benjaminkomen)) ([#46624](https://github.com/expo/expo/pull/46624) by [@nishan](https://github.com/intergalacticspacehighway))
24
31
 
@@ -27,7 +27,7 @@ if (shouldIncludeCompose) {
27
27
  }
28
28
 
29
29
  group = 'host.exp.exponent'
30
- version = '56.0.16'
30
+ version = '56.0.17'
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.16"
97
+ versionName "56.0.17"
98
98
  buildConfigField "String", "EXPO_MODULES_CORE_VERSION", "\"${versionName}\""
99
99
  buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", "true"
100
100
 
@@ -17,6 +17,10 @@ import androidx.annotation.UiThread
17
17
  import androidx.compose.ui.platform.ComposeView
18
18
  import androidx.compose.ui.platform.ViewCompositionStrategy
19
19
  import androidx.core.view.size
20
+ import androidx.lifecycle.LifecycleOwner
21
+ import androidx.lifecycle.setViewTreeLifecycleOwner
22
+ import androidx.savedstate.SavedStateRegistryOwner
23
+ import androidx.savedstate.setViewTreeSavedStateRegistryOwner
20
24
  import expo.modules.kotlin.AppContext
21
25
  import expo.modules.kotlin.exception.CodedException
22
26
  import expo.modules.kotlin.types.enforceType
@@ -52,10 +56,14 @@ abstract class ExpoComposeView<T : ComposeProps>(
52
56
  context: Context,
53
57
  appContext: AppContext,
54
58
  private val withHostingView: Boolean = false
55
- ) : ExpoView(context, appContext) {
59
+ ) : ExpoView(context, appContext), ComposeHostingView {
56
60
  open val props: T? = null
57
61
  protected var recomposeScope: RecomposeScope? = null
58
62
 
63
+ // Retained so the composition can be disposed on unmount: its strategy is
64
+ // pinned to the Activity lifecycle, so nothing disposes it on window detach.
65
+ private var hostingComposeView: ComposeView? = null
66
+
59
67
  private val globalEvent = ViewEvent<Pair<String, Map<String, Any?>>>(GLOBAL_EVENT_NAME, this, null)
60
68
 
61
69
  /**
@@ -191,22 +199,56 @@ abstract class ExpoComposeView<T : ComposeProps>(
191
199
 
192
200
  private fun addComposeView() {
193
201
  val composeView = ComposeView(context).also {
202
+ // Give each Host a unique id so its rememberSaveable state gets its own key.
203
+ // All Hosts share the Activity's SavedStateRegistry (set below), so without an id
204
+ // they'd collide on one key and only the first could save/restore state.
205
+ it.id = generateViewId()
194
206
  it.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
195
- it.setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
207
+ // Pin the composition to the Activity lifecycle so it survives
208
+ // react-native-screens detaching inactive screens on every switch.
209
+ // The strategy alone isn't enough: Compose's WrappedComposition also
210
+ // observes the view-tree lifecycle owner found at first attach — the
211
+ // screen fragment's, which RN-screens destroys per switch — and
212
+ // self-disposes on its ON_DESTROY, leaving a dead composition that
213
+ // never recreates. Overriding the owners on the ComposeView (nearest
214
+ // tag wins) points both at the Activity. Unmount disposes explicitly
215
+ // via disposeHostedComposition().
216
+ val activity = appContext.currentActivity
217
+ if (activity is LifecycleOwner && activity is SavedStateRegistryOwner) {
218
+ it.setViewTreeLifecycleOwner(activity)
219
+ it.setViewTreeSavedStateRegistryOwner(activity)
220
+ it.setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnLifecycleDestroyed(activity.lifecycle))
221
+ } else {
222
+ // No Activity to pin to: keep the prior behavior, including the
223
+ // dispose-on-reattach workaround for blank compositions after
224
+ // navigation (https://github.com/expo/expo/pull/34689).
225
+ it.setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
226
+ it.addOnAttachStateChangeListener(
227
+ OnAttachAfterDetachmentListener(onAttachAfterDetachment = {
228
+ it.disposeComposition()
229
+ })
230
+ )
231
+ }
196
232
  it.setContent {
197
233
  with(ComposableScope()) {
198
234
  Content()
199
235
  }
200
236
  }
201
- it.addOnAttachStateChangeListener(
202
- OnAttachAfterDetachmentListener(onAttachAfterDetachment = {
203
- it.disposeComposition()
204
- })
205
- )
206
237
  }
238
+ hostingComposeView = composeView
207
239
  addView(composeView)
208
240
  }
209
241
 
242
+ override fun disposeHostedComposition() {
243
+ hostingComposeView?.let {
244
+ // disposeComposition() alone leaves the composition strategy's lifecycle observer
245
+ // registered on the Activity, which leaks this view.
246
+ // Swapping the strategy first detaches that observer, then we dispose.
247
+ it.setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow)
248
+ it.disposeComposition()
249
+ }
250
+ }
251
+
210
252
  override fun addView(child: View, index: Int, params: ViewGroup.LayoutParams) {
211
253
  val view = if (child !is ExpoComposeView<*> && child !is ComposeView && this !is RNHostViewInterface) {
212
254
  ExpoComposeAndroidView(child, appContext)
@@ -9,8 +9,8 @@ namespace expo {
9
9
  void NativeStatePropsGetter::registerNatives() {
10
10
  javaClassLocal()->registerNatives({
11
11
  makeNativeMethod("getStateProps", NativeStatePropsGetter::getStateProps),
12
- makeNativeMethod("updateStyleSizeImmediate", NativeStatePropsGetter::updateStyleSizeImmediate),
13
- makeNativeMethod("updateViewSizeImmediate", NativeStatePropsGetter::updateViewSizeImmediate),
12
+ makeNativeMethod("updateStyleSizeImmediateImpl", NativeStatePropsGetter::updateStyleSizeImmediate),
13
+ makeNativeMethod("updateViewSizeImmediateImpl", NativeStatePropsGetter::updateViewSizeImmediate),
14
14
  });
15
15
  }
16
16
 
@@ -1,5 +1,6 @@
1
1
  package expo.modules.kotlin.jni.fabric
2
2
 
3
+ import com.facebook.jni.HybridData
3
4
  import com.facebook.yoga.annotations.DoNotStrip
4
5
 
5
6
  @DoNotStrip
@@ -8,8 +9,31 @@ class NativeStatePropsGetter {
8
9
  external fun getStateProps(stateWrapper: Any): Map<String, Any?>?
9
10
 
10
11
  // Synchronously flush a style property size update in the current frame (pass NaN for "unset").
11
- external fun updateStyleSizeImmediate(stateWrapper: Any, styleWidth: Double, styleHeight: Double)
12
+ fun updateStyleSizeImmediate(stateWrapper: Any, styleWidth: Double, styleHeight: Double) {
13
+ if (!isStateValid(stateWrapper)) {
14
+ return
15
+ }
16
+ updateStyleSizeImmediateImpl(stateWrapper, styleWidth, styleHeight)
17
+ }
12
18
 
13
19
  // Synchronously flush a size update in the current frame.
14
- external fun updateViewSizeImmediate(stateWrapper: Any, width: Double, height: Double)
20
+ fun updateViewSizeImmediate(stateWrapper: Any, width: Double, height: Double) {
21
+ if (!isStateValid(stateWrapper)) {
22
+ return
23
+ }
24
+ updateViewSizeImmediateImpl(stateWrapper, width, height)
25
+ }
26
+
27
+ private external fun updateStyleSizeImmediateImpl(
28
+ stateWrapper: Any,
29
+ styleWidth: Double,
30
+ styleHeight: Double
31
+ )
32
+
33
+ private external fun updateViewSizeImmediateImpl(stateWrapper: Any, width: Double, height: Double)
34
+
35
+ // The only `StateWrapper` is RN's `StateWrapperImpl`, a fbjni `HybridData`. When the shadow node is
36
+ // destroyed its native pointer is reset and calling into it throws. Skip the update like RN does
37
+ // before its own native state accesses.
38
+ private fun isStateValid(stateWrapper: Any): Boolean = (stateWrapper as? HybridData)?.isValid == true
15
39
  }
@@ -148,6 +148,7 @@ class ViewManagerWrapperDelegate(
148
148
 
149
149
  fun onDestroy(view: View) {
150
150
  try {
151
+ (view as? ComposeHostingView)?.disposeHostedComposition()
151
152
  definition.onViewDestroys?.invoke(view)
152
153
  } catch (exception: Throwable) {
153
154
  // The view wasn't constructed correctly, so errors are expected.
@@ -177,3 +178,11 @@ class ViewManagerWrapperDelegate(
177
178
  }
178
179
  }
179
180
  }
181
+
182
+ /**
183
+ * Implemented by Compose-based views to dispose their composition when the
184
+ * view is destroyed.
185
+ */
186
+ interface ComposeHostingView {
187
+ fun disposeHostedComposition()
188
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-modules-core",
3
- "version": "56.0.16",
3
+ "version": "56.0.17",
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.9",
50
+ "expo-modules-jsi": "~56.0.10",
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": "b1e94a5c1c5b19472a42ca25752a3533699bc46a",
69
+ "gitHead": "812dc007aefed0c432c0439fdfe05ee2f4f21da2",
70
70
  "scripts": {
71
71
  "build": "expo-module build",
72
72
  "clean": "expo-module clean",