expo-modules-core 56.0.15 → 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.
Files changed (23) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/android/build.gradle +2 -2
  3. package/android/src/compose/expo/modules/kotlin/views/ExpoComposeView.kt +51 -8
  4. package/android/src/compose/expo/modules/kotlin/views/ModuleDefinitionBuilderComposeExtension.kt +1 -1
  5. package/android/src/main/cpp/fabric/NativeStatePropsGetter.cpp +45 -0
  6. package/android/src/main/cpp/fabric/NativeStatePropsGetter.h +17 -0
  7. package/android/src/main/java/expo/modules/kotlin/jni/fabric/NativeStatePropsGetter.kt +30 -0
  8. package/android/src/main/java/expo/modules/kotlin/viewevent/ViewEvent.kt +19 -14
  9. package/android/src/main/java/expo/modules/kotlin/views/ShadowNodeProxy.kt +40 -10
  10. package/android/src/main/java/expo/modules/kotlin/views/ViewFunctionHolder.kt +5 -2
  11. package/android/src/main/java/expo/modules/kotlin/views/ViewManagerWrapperDelegate.kt +9 -0
  12. package/ios/Core/Arguments/Convertibles.swift +8 -1
  13. package/ios/Core/DynamicTypes/DynamicConvertibleType.swift +21 -6
  14. package/ios/Core/ExpoModulesMacros.swift +44 -6
  15. package/ios/Core/ModuleHolder.swift +12 -6
  16. package/ios/Core/Protocols/AnyModule.swift +16 -2
  17. package/ios/Core/Records/FormattedRecord.swift +3 -3
  18. package/ios/Core/Records/Record.swift +61 -5
  19. package/package.json +4 -4
  20. package/prebuilds/output/debug/xcframeworks/ExpoModulesCore.tar.gz +0 -0
  21. package/prebuilds/output/debug/xcframeworks/ExpoModulesWorklets.tar.gz +0 -0
  22. package/prebuilds/output/release/xcframeworks/ExpoModulesCore.tar.gz +0 -0
  23. package/prebuilds/output/release/xcframeworks/ExpoModulesWorklets.tar.gz +0 -0
package/CHANGELOG.md CHANGED
@@ -10,6 +10,29 @@
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
+
19
+ ## 56.0.16 — 2026-06-10
20
+
21
+ ### 🎉 New features
22
+
23
+ - [iOS] Added the `@Record` macro that synthesizes a record from a type's stored properties, with no `@Field` wrappers needed. ([#46547](https://github.com/expo/expo/pull/46547) by [@tsapeta](https://github.com/tsapeta))
24
+
25
+ ### 🐛 Bug fixes
26
+
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))
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))
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))
31
+
32
+ ### 💡 Others
33
+
34
+ - [iOS] `@ExpoModule` now synthesizes a `_decorateModule` that binds the module's `@JS` functions directly onto the JS object, letting the module holder skip the dynamic definition path for synthesized modules. ([#46612](https://github.com/expo/expo/pull/46612) by [@tsapeta](https://github.com/tsapeta))
35
+
13
36
  ## 56.0.15 — 2026-06-05
14
37
 
15
38
  ### 🐛 Bug fixes
@@ -27,7 +27,7 @@ if (shouldIncludeCompose) {
27
27
  }
28
28
 
29
29
  group = 'host.exp.exponent'
30
- version = '56.0.15'
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.15"
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)
@@ -511,7 +553,8 @@ class ComposeFunctionHolder<Props : ComposeProps>(
511
553
  appContext: AppContext,
512
554
  override val name: String,
513
555
  private val composableContent: @Composable FunctionalComposableScope.(props: Props) -> Unit,
514
- override val props: Props
556
+ override val props: Props,
557
+ override val callbacksDefinition: CallbacksDefinition?
515
558
  ) : ExpoComposeView<Props>(context, appContext), ViewFunctionHolder {
516
559
  val propsMutableState = mutableStateOf(props)
517
560
 
@@ -143,7 +143,7 @@ class ComposeViewFunctionDefinitionBuilder<Props : ComposeProps> @PublishedApi i
143
143
  } catch (e: Exception) {
144
144
  throw IllegalStateException("Could not instantiate props instance of $name compose component.", e)
145
145
  }
146
- ComposeFunctionHolder(context, appContext, name, viewFunction, instance)
146
+ ComposeFunctionHolder(context, appContext, name, viewFunction, instance, eventBuilder.callbacksDefinition)
147
147
  },
148
148
  callbacksDefinition = eventBuilder.callbacksDefinition,
149
149
  viewType = ComposeFunctionHolder::class.java,
@@ -9,9 +9,54 @@ namespace expo {
9
9
  void NativeStatePropsGetter::registerNatives() {
10
10
  javaClassLocal()->registerNatives({
11
11
  makeNativeMethod("getStateProps", NativeStatePropsGetter::getStateProps),
12
+ makeNativeMethod("updateStyleSizeImmediateImpl", NativeStatePropsGetter::updateStyleSizeImmediate),
13
+ makeNativeMethod("updateViewSizeImmediateImpl", NativeStatePropsGetter::updateViewSizeImmediate),
12
14
  });
13
15
  }
14
16
 
17
+ static std::shared_ptr<const react::ConcreteState<AndroidExpoViewState>> concreteStateFrom(
18
+ jni::alias_ref<jobject> stateWrapper
19
+ ) {
20
+ auto stateWrapperImpl = jni::alias_ref<react::StateWrapperImpl::javaobject>{
21
+ static_cast<react::StateWrapperImpl::javaobject>(stateWrapper.get())
22
+ };
23
+ return std::dynamic_pointer_cast<const react::ConcreteState<AndroidExpoViewState>>(
24
+ stateWrapperImpl->cthis()->getState()
25
+ );
26
+ }
27
+
28
+ void NativeStatePropsGetter::updateStyleSizeImmediate(
29
+ jni::alias_ref<NativeStatePropsGetter::javaobject> self,
30
+ jni::alias_ref<jobject> stateWrapper,
31
+ jdouble styleWidth,
32
+ jdouble styleHeight
33
+ ) {
34
+ const auto state = concreteStateFrom(stateWrapper);
35
+ if (state == nullptr) {
36
+ return;
37
+ }
38
+ AndroidExpoViewState newState;
39
+ newState._styleWidth = static_cast<float>(styleWidth);
40
+ newState._styleHeight = static_cast<float>(styleHeight);
41
+ state->updateState(std::move(newState), react::EventQueue::UpdateMode::unstable_Immediate);
42
+ }
43
+
44
+ void NativeStatePropsGetter::updateViewSizeImmediate(
45
+ jni::alias_ref<NativeStatePropsGetter::javaobject> self,
46
+ jni::alias_ref<jobject> stateWrapper,
47
+ jdouble width,
48
+ jdouble height
49
+ ) {
50
+ const auto state = concreteStateFrom(stateWrapper);
51
+ if (state == nullptr) {
52
+ return;
53
+ }
54
+ AndroidExpoViewState newState;
55
+ newState._width = static_cast<float>(width);
56
+ newState._height = static_cast<float>(height);
57
+ state->updateState(std::move(newState), react::EventQueue::UpdateMode::unstable_Immediate);
58
+ }
59
+
15
60
  jni::local_ref<jni::JMap<jstring, jobject>> NativeStatePropsGetter::getStateProps(
16
61
  jni::alias_ref<NativeStatePropsGetter::javaobject> self,
17
62
  jni::alias_ref<jobject> stateWrapper
@@ -18,6 +18,23 @@ public:
18
18
  jni::alias_ref<NativeStatePropsGetter::javaobject> self,
19
19
  jni::alias_ref<jobject> stateWrapper
20
20
  );
21
+
22
+ // Synchronously flushes a shadow-node size state update in the current frame
23
+ // (`UpdateMode::unstable_Immediate`) — the same path iOS uses. This avoids the layout shift an
24
+ // asynchronous `StateWrapper.updateState` causes for matchContents Hosts in Expo UI Compose views
25
+ static void updateStyleSizeImmediate(
26
+ jni::alias_ref<NativeStatePropsGetter::javaobject> self,
27
+ jni::alias_ref<jobject> stateWrapper,
28
+ jdouble styleWidth,
29
+ jdouble styleHeight
30
+ );
31
+
32
+ static void updateViewSizeImmediate(
33
+ jni::alias_ref<NativeStatePropsGetter::javaobject> self,
34
+ jni::alias_ref<jobject> stateWrapper,
35
+ jdouble width,
36
+ jdouble height
37
+ );
21
38
  };
22
39
 
23
40
  } // namespace expo
@@ -1,9 +1,39 @@
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
6
7
  class NativeStatePropsGetter {
7
8
  // We can't use StateWrapper directly, as this class is not exposed
8
9
  external fun getStateProps(stateWrapper: Any): Map<String, Any?>?
10
+
11
+ // Synchronously flush a style property size update in the current frame (pass NaN for "unset").
12
+ fun updateStyleSizeImmediate(stateWrapper: Any, styleWidth: Double, styleHeight: Double) {
13
+ if (!isStateValid(stateWrapper)) {
14
+ return
15
+ }
16
+ updateStyleSizeImmediateImpl(stateWrapper, styleWidth, styleHeight)
17
+ }
18
+
19
+ // Synchronously flush a size update in the current frame.
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
9
39
  }
@@ -4,10 +4,12 @@ import android.view.View
4
4
  import com.facebook.react.bridge.ReactContext
5
5
  import com.facebook.react.bridge.WritableMap
6
6
  import expo.modules.core.utilities.ifNull
7
+ import expo.modules.kotlin.ModuleRegistry
7
8
  import expo.modules.kotlin.getUnimoduleProxy
8
9
  import expo.modules.kotlin.logger
9
10
  import expo.modules.kotlin.types.JSTypeConverterProvider
10
11
  import expo.modules.kotlin.types.putGeneric
12
+ import expo.modules.kotlin.views.CallbacksDefinition
11
13
  import expo.modules.kotlin.views.ViewFunctionHolder
12
14
 
13
15
  fun interface ViewEventCallback<T> {
@@ -27,24 +29,13 @@ open class ViewEvent<T>(
27
29
  val appContext = nativeModulesProxy.kotlinInteropModuleRegistry.appContext
28
30
 
29
31
  if (!isValidated) {
30
- val holder = appContext.registry.getModuleHolder(view::class.java).ifNull {
31
- logger.warn("⚠️ Cannot get module holder for ${view::class.java}")
32
- return
33
- }
34
-
35
- val callbacksDefinition = if (view is ViewFunctionHolder) {
36
- appContext.registry.getViewDefinition(holder, view.name)?.callbacksDefinition
37
- } else {
38
- appContext.registry.getViewDefinition(holder, view::class.java)?.callbacksDefinition
39
- }
40
-
41
- val callbacks = callbacksDefinition.ifNull {
42
- logger.warn("⚠️ Cannot get callbacks for ${holder.module::class.java}")
32
+ val callbacks = resolveCallbacksDefinition(view, appContext.registry).ifNull {
33
+ logger.warn("⚠️ Cannot get callbacks for ${view::class.java}")
43
34
  return
44
35
  }
45
36
 
46
37
  if (!callbacks.names.any { it == name }) {
47
- logger.warn("⚠️ Event $name wasn't exported from ${holder.module::class.java}")
38
+ logger.warn("⚠️ Event $name wasn't exported from ${view::class.java}")
48
39
  return
49
40
  }
50
41
 
@@ -71,3 +62,17 @@ open class ViewEvent<T>(
71
62
  }
72
63
  }
73
64
  }
65
+
66
+ /**
67
+ * Resolves the callbacks declared for [view]. Views that reuse a single class
68
+ * (e.g. ComposeFunctionHolder) carry their own [CallbacksDefinition] because they
69
+ * can't be matched by class; everything else is looked up by class in the registry.
70
+ * See https://github.com/expo/expo/issues/46623.
71
+ */
72
+ internal fun resolveCallbacksDefinition(view: View, registry: ModuleRegistry): CallbacksDefinition? {
73
+ if (view is ViewFunctionHolder) {
74
+ return view.callbacksDefinition
75
+ }
76
+ val holder = registry.getModuleHolder(view::class.java) ?: return null
77
+ return registry.getViewDefinition(holder, view::class.java)?.callbacksDefinition
78
+ }
@@ -1,23 +1,53 @@
1
1
  package expo.modules.kotlin.views
2
2
 
3
- import com.facebook.react.bridge.Arguments
3
+ import android.view.ViewTreeObserver
4
+ import expo.modules.kotlin.jni.fabric.NativeStatePropsGetter
4
5
  import java.lang.ref.WeakReference
5
6
 
6
7
  class ShadowNodeProxy(expoView: ExpoView) {
7
8
  val weakExpoView = WeakReference(expoView)
9
+ private val stateUpdater = NativeStatePropsGetter()
8
10
 
11
+ private var pendingFlush: ((stateWrapper: Any) -> Unit)? = null
12
+ private var preDrawListener: ViewTreeObserver.OnPreDrawListener? = null
13
+
14
+ // Schedule in predraw listener to avoid early return in re-entrancy
15
+ // We have a proper fix [here](https://github.com/facebook/react-native/pull/56311)
16
+ // but it needs to be merged in RN
17
+ // TODO: Remove the workaround when RN PR gets merged.
9
18
  fun setViewSize(width: Double, height: Double) {
10
- weakExpoView.get()?.stateWrapper?.updateState(Arguments.makeNativeMap(mapOf("width" to width, "height" to height)))
19
+ scheduleFlush { stateWrapper ->
20
+ stateUpdater.updateViewSizeImmediate(stateWrapper, width, height)
21
+ }
11
22
  }
12
23
 
13
24
  fun setStyleSize(width: Double?, height: Double?) {
14
- weakExpoView.get()?.stateWrapper?.updateState(
15
- Arguments.makeNativeMap(
16
- mapOf(
17
- "styleWidth" to width,
18
- "styleHeight" to height
19
- )
20
- )
21
- )
25
+ scheduleFlush { stateWrapper ->
26
+ stateUpdater.updateStyleSizeImmediate(stateWrapper, width ?: Double.NaN, height ?: Double.NaN)
27
+ }
28
+ }
29
+
30
+ private fun scheduleFlush(flush: (stateWrapper: Any) -> Unit) {
31
+ 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
48
+ }
49
+ }
50
+ preDrawListener = listener
51
+ observer.addOnPreDrawListener(listener)
22
52
  }
23
53
  }
@@ -1,9 +1,12 @@
1
1
  package expo.modules.kotlin.views
2
2
 
3
3
  /*
4
- * A marker interface identifying views reusing a single class (like ComposeFunctionHolder)
5
- * that should be identified by name for things like event validation.
4
+ * A marker interface for views that reuse a single class (like ComposeFunctionHolder) and so
5
+ * can't be resolved by class. They're identified by [name] and carry their
6
+ * own [callbacksDefinition], which event invocation reads directly instead of looking it up in the
7
+ * registry. See https://github.com/expo/expo/issues/46623.
6
8
  */
7
9
  interface ViewFunctionHolder {
8
10
  val name: String
11
+ val callbacksDefinition: CallbacksDefinition?
9
12
  }
@@ -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
+ }
@@ -153,7 +153,14 @@ extension Date: Convertible {
153
153
  }
154
154
  return date
155
155
  }
156
- // For converting the value from `Date.now()`
156
+ // JS numbers arrive across the JSI bridge as Swift Double (all JS numbers
157
+ // are doubles). `as? Int` does NOT downcast a Double, so without this
158
+ // branch any JS caller passing `someDate.getTime()` to a `Date` / `Date?`
159
+ // argument throws ConvertingException<Date>.
160
+ if let value = value as? Double {
161
+ return Date(timeIntervalSince1970: value / 1000.0)
162
+ }
163
+ // Kept for parity with explicit Int values (rare but possible from native callers).
157
164
  if let value = value as? Int {
158
165
  return Date(timeIntervalSince1970: Double(value) / 1000.0)
159
166
  }
@@ -21,10 +21,10 @@ internal struct DynamicConvertibleType: AnyDynamicType {
21
21
 
22
22
  @JavaScriptActor
23
23
  func cast(jsValue: JavaScriptValue, appContext: AppContext) throws -> Any {
24
+ // `from(object:)` dispatches dynamically: reflection-based hydration for `@Field` records,
25
+ // a direct statically-typed factory for `@Record`-synthesized ones.
24
26
  if let recordType = innerType as? any Record.Type {
25
- let record = recordType.init()
26
- try record.update(withObject: try jsValue.asObject(), appContext: appContext)
27
- return record
27
+ return try recordType.from(object: jsValue.asObject(), appContext: appContext)
28
28
  }
29
29
  return try innerType.convert(from: jsValue.getAny(), appContext: appContext)
30
30
  }
@@ -53,6 +53,19 @@ internal struct DynamicConvertibleType: AnyDynamicType {
53
53
  return try convertOriginalValueToJS(value, appContext: appContext)
54
54
  }
55
55
 
56
+ // The protocol default for the `in: runtime` variant pre-converts the value via
57
+ // `convertFunctionResult` (which calls `convertResult` → `Record.toDictionary` for records) before
58
+ // reaching `castToJS`, defeating the direct object path. Override it to try the direct path first —
59
+ // the same short-circuit as the no-runtime `convertToJS` above — so records (including
60
+ // `@Record`-synthesized ones) convert straight through `toObject` on the return side too.
61
+ func convertToJS<ValueType>(_ value: ValueType, appContext: AppContext, in runtime: JavaScriptRuntime) throws -> JavaScriptValue {
62
+ if let directJSValue = try directJSValueIfPossible(value, appContext: appContext) {
63
+ return directJSValue
64
+ }
65
+ let result = Conversions.convertFunctionResult(value, appContext: appContext, dynamicType: self)
66
+ return try castToJS(result, appContext: appContext, in: runtime)
67
+ }
68
+
56
69
  func convertResult<ResultType>(_ result: ResultType, appContext: AppContext) throws -> Any {
57
70
  return try innerType.convertResult(result, appContext: appContext)
58
71
  }
@@ -62,15 +75,17 @@ internal struct DynamicConvertibleType: AnyDynamicType {
62
75
  }
63
76
 
64
77
  private func directJSValueIfPossible<ValueType>(_ value: ValueType, appContext: AppContext) throws -> JavaScriptValue? {
78
+ // `toObject` is a `Record` requirement, so the synthesized override dispatches dynamically;
79
+ // `@Field` records fall through to the reflection-based default.
65
80
  if let value = value as? any Record {
66
81
  return try JavaScriptActor.assumeIsolated {
67
- try value.toJSValue(appContext: appContext)
82
+ try value.toObject(appContext: appContext).asValue()
68
83
  }
69
84
  }
70
85
  // `FormattedRecord` isn't a `Record`, so it needs this separate branch to preserve the direct path.
71
- if let value = value as? any RecordJavaScriptValueConvertible {
86
+ if let value = value as? any RecordObjectConvertible {
72
87
  return try JavaScriptActor.assumeIsolated {
73
- try value.toJSValue(appContext: appContext)
88
+ try value.toObject(appContext: appContext).asValue()
74
89
  }
75
90
  }
76
91
  return nil
@@ -38,14 +38,21 @@ public macro OptimizedFunction() =
38
38
  ///
39
39
  /// @JS
40
40
  /// var status: String { "ok" }
41
- @attached(peer)
41
+ ///
42
+ /// Also emits a never-called `_assertTypesConformance_<member>` peer that statically asserts every
43
+ /// type crossing the JS boundary conforms to the JS-convertible protocol, so a non-conforming type
44
+ /// fails to compile on the user's declaration. The peer name embeds the member name, hence
45
+ /// `names: arbitrary`.
46
+ @attached(peer, names: arbitrary)
42
47
  public macro JS(_ jsName: String? = nil) =
43
48
  #externalMacro(module: "ExpoModulesMacros", type: "JSMacro")
44
49
 
45
50
  /// Member macro applied to a `Module` subclass. Scans the class body for declarations
46
- /// marked with `@JS` and synthesizes a framework-internal `_exposedDefinition()` method.
51
+ /// marked with `@JS` and synthesizes a framework-internal `_synthesizedDefinition()` method.
47
52
  /// `expo-modules-core` calls it automatically and merges the result into the module's
48
- /// definition, so the user doesn't have to reference it from `definition()`.
53
+ /// definition, so the user doesn't have to reference it from `definition()`. `@JS` functions are
54
+ /// additionally bound directly into the module's JS object by a synthesized
55
+ /// `_decorateModule(object:in:appContext:)`.
49
56
  ///
50
57
  /// Usage:
51
58
  ///
@@ -58,13 +65,15 @@ public macro JS(_ jsName: String? = nil) =
58
65
  /// @JS
59
66
  /// func greet(name: String) -> String { "Hi, \(name)" }
60
67
  /// }
61
- @attached(member, names: named(_exposedDefinition), named(appContext), named(init))
68
+ @attached(
69
+ member,
70
+ names: named(_synthesizedDefinition), named(appContext), named(init), named(_decorateModule))
62
71
  public macro ExpoModule(_ name: String? = nil, classes: [Any.Type] = []) =
63
72
  #externalMacro(module: "ExpoModulesMacros", type: "ExpoModuleMacro")
64
73
 
65
74
  /// Member macro applied to a `SharedObject` subclass. Scans the class body for declarations
66
75
  /// marked with `@JS` (including a single `@JS init(...)` for the JS constructor) and
67
- /// synthesizes a `_exposedClassDefinition()` static method returning a `ClassDefinition`.
76
+ /// synthesizes a `_synthesizedClassDefinition()` static method returning a `ClassDefinition`.
68
77
  /// The companion `@ExpoModule(classes: [Foo.self])` wires the class into the module's
69
78
  /// exposed surface.
70
79
  ///
@@ -81,6 +90,35 @@ public macro ExpoModule(_ name: String? = nil, classes: [Any.Type] = []) =
81
90
  /// @JS
82
91
  /// var size: Int { 42 }
83
92
  /// }
84
- @attached(member, names: named(_exposedClassDefinition))
93
+ @attached(member, names: named(_synthesizedClassDefinition))
85
94
  public macro SharedObject(_ name: String? = nil) =
86
95
  #externalMacro(module: "ExpoModulesMacros", type: "SharedObjectMacro")
96
+
97
+ /// Member + extension macro applied to a record `struct` or `class`. Every non-`static`,
98
+ /// non-`private`/`fileprivate`, non-`lazy`, non-computed stored property is part of the record — no
99
+ /// `@Field` wrapper needed — and the macro synthesizes the whole conversion surface from each
100
+ /// property's static type: a memberwise `init`, the `from(object:appContext:)` /
101
+ /// `from(dictionary:appContext:)` factories, and the `toDictionary(appContext:)` /
102
+ /// `toObject(appContext:)` write side. The type is auto-conformed to `Record`; the synthesized
103
+ /// methods override `Record`'s reflection-based defaults, so it stays usable anywhere a `Record`
104
+ /// argument is expected.
105
+ ///
106
+ /// Requiredness is inferred from each property: a default value makes it optional, an optional type
107
+ /// makes it nullable and optional, and a non-optional property without a default is required (the
108
+ /// factories throw `RecordPropertyRequiredException` when the source omits it).
109
+ ///
110
+ /// Usage:
111
+ ///
112
+ /// @Record
113
+ /// struct Options {
114
+ /// var name: String // required
115
+ /// var count: Int = 0 // optional (has default)
116
+ /// var note: String? // nullable + optional
117
+ /// }
118
+ @attached(
119
+ member,
120
+ names: named(init), named(from), named(toDictionary), named(toObject),
121
+ named(_assertTypesConformance))
122
+ @attached(extension, conformances: Record)
123
+ public macro Record() =
124
+ #externalMacro(module: "ExpoModulesMacros", type: "RecordMacro")
@@ -52,20 +52,20 @@ public final class ModuleHolder {
52
52
 
53
53
  /// Combines the user-authored definition with the entries synthesized by the
54
54
  /// `@ExpoModule` macro on this module's class (if any). The macro emits a
55
- /// `_exposedDefinition()` method returning an `[AnyDefinition]` array of the
55
+ /// `_synthesizedDefinition()` method returning an `[AnyDefinition]` array of the
56
56
  /// `Function` / `Property` / `Constructor` entries it generated from `@JS`
57
57
  /// members. Those entries are prepended to the user's definitions and the
58
58
  /// whole list is fed back through `ModuleDefinition.init` so the merged
59
59
  /// result is rebucketed (into `functions`, `properties`, etc.) just like a
60
60
  /// hand-written definition. Modules that don't use the macro fall through
61
- /// the empty-exposed fast path and return the user's definition unchanged.
61
+ /// the empty-synthesized fast path and return the user's definition unchanged.
62
62
  private static func buildDefinition(for module: AnyModule) -> ModuleDefinition {
63
63
  let userDefinition = module.definition()
64
- let exposed = module._exposedDefinition()
65
- if exposed.isEmpty {
64
+ let synthesized = module._synthesizedDefinition()
65
+ if synthesized.isEmpty {
66
66
  return userDefinition
67
67
  }
68
- return ModuleDefinition(definitions: exposed + userDefinition.rawDefinitions)
68
+ return ModuleDefinition(definitions: synthesized + userDefinition.rawDefinitions)
69
69
  }
70
70
 
71
71
  // MARK: Constants
@@ -106,7 +106,13 @@ public final class ModuleHolder {
106
106
  }
107
107
  do {
108
108
  log.info("Creating JS object for module '\(name)'")
109
- return try definition.build(appContext: appContext)
109
+ let object = try definition.build(appContext: appContext)
110
+
111
+ // Install the `@JS` members the `@ExpoModule` macro binds directly into the JS object
112
+ // (the direct-JSI path). A no-op for modules that don't use the macro.
113
+ try module._decorateModule(object: object, in: appContext.runtime, appContext: appContext)
114
+
115
+ return object
110
116
  } catch {
111
117
  log.error("Building the module object failed: \(error)")
112
118
  return nil
@@ -1,3 +1,5 @@
1
+ import ExpoModulesJSI
2
+
1
3
  /**
2
4
  A protocol for any type-erased module that provides functions used by the core.
3
5
  */
@@ -19,11 +21,23 @@ public protocol AnyModule: AnyObject, AnyArgument {
19
21
  /// Framework-internal: the leading underscore signals this is not part of the public API and
20
22
  /// should only be called by `expo-modules-core` itself. Modules that don't use the macro fall
21
23
  /// back to the default empty implementation.
22
- func _exposedDefinition() -> [AnyDefinition]
24
+ func _synthesizedDefinition() -> [AnyDefinition]
25
+
26
+ /// Binds the module's `@JS` members directly into its JavaScript object, generated by the
27
+ /// `@ExpoModule` macro. Core calls this after building the module object so the synthesized
28
+ /// host functions are installed alongside the DSL-described surface. Framework-internal
29
+ /// (leading underscore). Modules that don't use the macro fall back to the default no-op.
30
+ @JavaScriptActor
31
+ func _decorateModule(object: borrowing JavaScriptObject, in runtime: JavaScriptRuntime, appContext: AppContext) throws
23
32
  }
24
33
 
25
34
  public extension AnyModule {
26
- func _exposedDefinition() -> [AnyDefinition] {
35
+ func _synthesizedDefinition() -> [AnyDefinition] {
27
36
  return []
28
37
  }
38
+
39
+ @JavaScriptActor
40
+ func _decorateModule(object: borrowing JavaScriptObject, in runtime: JavaScriptRuntime, appContext: AppContext) throws {
41
+ // No-op by default — only `@ExpoModule`-macro modules synthesize a real implementation.
42
+ }
29
43
  }
@@ -2,7 +2,7 @@
2
2
  Class that binds a formatter with a record.
3
3
  It can be converted to JS, but it can't be converted from a JS value.
4
4
  */
5
- public struct FormattedRecord<RecordType: Record>: Convertible, RecordJavaScriptValueConvertible {
5
+ public struct FormattedRecord<RecordType: Record>: Convertible, RecordObjectConvertible {
6
6
  internal final class FormattedRecordCannotBeUsedAsParameterException: Exception, @unchecked Sendable {
7
7
  override var reason: String {
8
8
  "FormattedRecord cannot be used as a parameter"
@@ -39,7 +39,7 @@ public struct FormattedRecord<RecordType: Record>: Convertible, RecordJavaScript
39
39
  }
40
40
 
41
41
  @JavaScriptActor
42
- func toJSValue(appContext: AppContext) throws -> JavaScriptValue {
42
+ func toObject(appContext: AppContext) throws -> JavaScriptObject {
43
43
  let object = try appContext.runtime.createObject()
44
44
 
45
45
  for field in fieldsOf(record) {
@@ -58,6 +58,6 @@ public struct FormattedRecord<RecordType: Record>: Convertible, RecordJavaScript
58
58
  let jsValue = try recordFieldValueToJSValue(value, appContext: appContext)
59
59
  object.setProperty(key, value: jsValue)
60
60
  }
61
- return object.asValue()
61
+ return object
62
62
  }
63
63
  }
@@ -20,15 +20,53 @@ public protocol Record: Convertible {
20
20
  */
21
21
  init(from: Dict, appContext: AppContext) throws
22
22
 
23
+ /**
24
+ Reads the record's members off a `JavaScriptObject`. The `@Field`-based default goes through
25
+ reflection (`init()` + `update(withObject:)`); the `@Record` macro overrides it with a direct,
26
+ factory that reads each stored property by its declared type.
27
+ */
28
+ @JavaScriptActor
29
+ static func from(object: borrowing JavaScriptObject, appContext: AppContext) throws -> Self
30
+
31
+ /**
32
+ Reads the record's members from a `[String: Any]` dictionary. The `@Field`-based default goes
33
+ through reflection (`init()` + `update(withDict:)`); the `@Record` macro overrides it with a
34
+ direct, statically-typed factory.
35
+ */
36
+ static func from(dictionary: Dict, appContext: AppContext) throws -> Self
37
+
23
38
  /**
24
39
  Converts the record back to the dictionary. Only members wrapped by `@Field` will be set in the dictionary.
25
40
  */
26
41
  func toDictionary(appContext: AppContext?) -> Dict
42
+
43
+ /**
44
+ Converts the record to a `JavaScriptObject`. The `@Field`-based default builds the object via
45
+ reflection; the `@Record` macro overrides it with a direct, statically-typed conversion.
46
+ */
47
+ @JavaScriptActor
48
+ func toObject(appContext: AppContext) throws -> JavaScriptObject
27
49
  }
28
50
 
29
- internal protocol RecordJavaScriptValueConvertible {
51
+ /**
52
+ Adopted by record-like types that produce a `JavaScriptObject` directly but aren't themselves a
53
+ `Record` — currently just `FormattedRecord`. Lets `DynamicConvertibleType` take the direct
54
+ object-building path for them, same as for records.
55
+ */
56
+ internal protocol RecordObjectConvertible {
30
57
  @JavaScriptActor
31
- func toJSValue(appContext: AppContext) throws -> JavaScriptValue
58
+ func toObject(appContext: AppContext) throws -> JavaScriptObject
59
+ }
60
+
61
+ /**
62
+ Thrown by a synthesized record's factories when a required property is missing from the source.
63
+ Public because the `@Record`-generated code lives in user modules and references it directly.
64
+ The `@Field`-based path has its own internal `FieldRequiredException`.
65
+ */
66
+ public final class RecordPropertyRequiredException: GenericException<String>, @unchecked Sendable {
67
+ override public var reason: String {
68
+ return "Value for property '\(param)' is required, got nil"
69
+ }
32
70
  }
33
71
 
34
72
  /**
@@ -37,7 +75,9 @@ internal protocol RecordJavaScriptValueConvertible {
37
75
  public extension Record {
38
76
  static func convert(from value: Any?, appContext: AppContext) throws -> Self {
39
77
  if let value = value as? Dict {
40
- return try Self(from: value, appContext: appContext)
78
+ // `from(dictionary:)` dispatches dynamically — reflection for `@Field` records, the
79
+ // synthesized factory for `@Record` ones — so both read correctly off a dictionary.
80
+ return try Self.from(dictionary: value, appContext: appContext)
41
81
  }
42
82
  // It's possible that the current implementation tries to convert a value that is already of the desired type.
43
83
  // Handle that gracefully instead of throwing an exception.
@@ -52,6 +92,22 @@ public extension Record {
52
92
  try update(withDict: dict, appContext: appContext)
53
93
  }
54
94
 
95
+ @JavaScriptActor
96
+ static func from(object: borrowing JavaScriptObject, appContext: AppContext) throws -> Self {
97
+ // Reflection-based default for `@Field` records. The `@Record` macro synthesizes a direct,
98
+ // statically-typed override that takes precedence over this.
99
+ let record = Self()
100
+ try record.update(withObject: object, appContext: appContext)
101
+ return record
102
+ }
103
+
104
+ static func from(dictionary: Dict, appContext: AppContext) throws -> Self {
105
+ // Reflection-based default for `@Field` records. The `@Record` macro synthesizes a direct,
106
+ // statically-typed override that takes precedence over this. Delegates to `init(from:appContext:)`
107
+ // so any custom dictionary initializer a record provides still runs.
108
+ return try Self(from: dictionary, appContext: appContext)
109
+ }
110
+
55
111
  func update(withDict dict: Dict, appContext: AppContext) throws {
56
112
  let dictKeys = dict.keys
57
113
 
@@ -107,7 +163,7 @@ public extension Record {
107
163
  }
108
164
 
109
165
  @JavaScriptActor
110
- func toJSValue(appContext: AppContext) throws -> JavaScriptValue {
166
+ func toObject(appContext: AppContext) throws -> JavaScriptObject {
111
167
  let object = try appContext.runtime.createObject()
112
168
 
113
169
  for field in fieldsOf(self) {
@@ -117,7 +173,7 @@ public extension Record {
117
173
  let value = try recordFieldValueToJSValue(field.get(), dynamicType: field.fieldType, appContext: appContext)
118
174
  object.setProperty(key, value: value)
119
175
  }
120
- return object.asValue()
176
+ return object
121
177
  }
122
178
  }
123
179
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-modules-core",
3
- "version": "56.0.15",
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",
@@ -46,8 +46,8 @@
46
46
  "preset": "expo-module-scripts"
47
47
  },
48
48
  "dependencies": {
49
- "@expo/expo-modules-macros-plugin": "~0.0.9",
50
- "expo-modules-jsi": "~56.0.8",
49
+ "@expo/expo-modules-macros-plugin": "0.2.2",
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": "175f1e78e3444ca99ddea473faea6777a0656668",
69
+ "gitHead": "812dc007aefed0c432c0439fdfe05ee2f4f21da2",
70
70
  "scripts": {
71
71
  "build": "expo-module build",
72
72
  "clean": "expo-module clean",