expo-modules-core 56.0.14 → 56.0.16

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 (37) hide show
  1. package/CHANGELOG.md +29 -4
  2. package/ExpoModulesCore.podspec +1 -1
  3. package/ExpoModulesWorklets.podspec +7 -0
  4. package/android/build.gradle +2 -2
  5. package/android/src/compose/expo/modules/kotlin/views/ExpoComposeView.kt +6 -1
  6. package/android/src/compose/expo/modules/kotlin/views/ModuleDefinitionBuilderComposeExtension.kt +1 -1
  7. package/android/src/main/cpp/fabric/NativeStatePropsGetter.cpp +45 -0
  8. package/android/src/main/cpp/fabric/NativeStatePropsGetter.h +17 -0
  9. package/android/src/main/java/expo/modules/kotlin/jni/fabric/NativeStatePropsGetter.kt +6 -0
  10. package/android/src/main/java/expo/modules/kotlin/viewevent/ViewEvent.kt +19 -14
  11. package/android/src/main/java/expo/modules/kotlin/views/ShadowNodeProxy.kt +40 -10
  12. package/android/src/main/java/expo/modules/kotlin/views/ViewFunctionHolder.kt +5 -2
  13. package/ios/AppDelegates/ExpoAppDelegateSubscriberManager.swift +20 -8
  14. package/ios/Core/AppContext.swift +2 -2
  15. package/ios/Core/Arguments/Convertibles.swift +8 -1
  16. package/ios/Core/Conversions.swift +1 -1
  17. package/ios/Core/DynamicTypes/DynamicConvertibleType.swift +21 -6
  18. package/ios/Core/DynamicTypes/DynamicSharedObjectType.swift +1 -1
  19. package/ios/Core/ExpoModulesMacros.swift +44 -6
  20. package/ios/Core/Functions/AsyncFunctionDefinition.swift +1 -3
  21. package/ios/Core/ModuleHolder.swift +12 -6
  22. package/ios/Core/Protocols/AnyModule.swift +16 -2
  23. package/ios/Core/Records/FormattedRecord.swift +3 -3
  24. package/ios/Core/Records/Record.swift +61 -5
  25. package/ios/JS/ExpoRuntimeInstaller.swift +1 -1
  26. package/ios/Platform/Platform.h +1 -0
  27. package/ios/Platform/Platform.swift +1 -0
  28. package/ios/Worklets/Core/DynamicSerializableType.swift +4 -0
  29. package/ios/Worklets/Core/Worklet.swift +10 -0
  30. package/ios/WorkletsTests/WorkletRuntimeIntegrationTests.swift +27 -0
  31. package/ios/WorkletsTests/WorkletsNotInstalledTests.swift +33 -0
  32. package/package.json +5 -5
  33. package/prebuilds/output/debug/xcframeworks/ExpoModulesCore.tar.gz +0 -0
  34. package/prebuilds/output/debug/xcframeworks/ExpoModulesWorklets.tar.gz +0 -0
  35. package/prebuilds/output/release/xcframeworks/ExpoModulesCore.tar.gz +0 -0
  36. package/prebuilds/output/release/xcframeworks/ExpoModulesWorklets.tar.gz +0 -0
  37. package/spm.config.json +2 -0
package/CHANGELOG.md CHANGED
@@ -10,6 +10,35 @@
10
10
 
11
11
  ### 💡 Others
12
12
 
13
+ ## 56.0.16 — 2026-06-10
14
+
15
+ ### 🎉 New features
16
+
17
+ - [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))
18
+
19
+ ### 🐛 Bug fixes
20
+
21
+ - [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))
22
+ - [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
+ - [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
+
25
+ ### 💡 Others
26
+
27
+ - [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))
28
+
29
+ ## 56.0.15 — 2026-06-05
30
+
31
+ ### 🐛 Bug fixes
32
+
33
+ - [iOS] Propagate async-function promise construction failures instead of trapping the app. ([#46106](https://github.com/expo/expo/issues/46106) by [@qutrek](https://github.com/qutrek)) ([#46145](https://github.com/expo/expo/pull/46145) by [@mvincentong](https://github.com/mvincentong))
34
+ - [iOS] Read iPad-specific supported orientations from `UISupportedInterfaceOrientations~ipad`. ([#46281](https://github.com/expo/expo/issues/46281) by [@bryandent](https://github.com/bryandent)) ([#46306](https://github.com/expo/expo/pull/46306) by [@mvincentong](https://github.com/mvincentong))
35
+ - [android] Fix nested `Host` double-composing children. ([#46304](https://github.com/expo/expo/pull/46304) by [@nishan](https://github.com/intergalacticspacehighway))
36
+ - [iOS] Throw an actionable error when a worklet is used but `react-native-worklets`'s native adapter isn't linked, instead of a misleading "not an instance of Worklet" failure. ([#46571](https://github.com/expo/expo/pull/46571) by [@chrfalch](https://github.com/chrfalch))
37
+
38
+ ### 💡 Others
39
+
40
+ - Native view config attributes now carry a `process` function that unwraps shared objects to their registry id, so callers can pass shared objects directly as view props instead of unwrapping them manually. ([#46212](https://github.com/expo/expo/pull/46212) by [@tsapeta](https://github.com/tsapeta))
41
+
13
42
  ## 56.0.14 — 2026-05-29
14
43
 
15
44
  _This version does not introduce any user-facing changes._
@@ -20,10 +49,6 @@ _This version does not introduce any user-facing changes._
20
49
 
21
50
  - [Android] Create Compose props without View. ([#46256](https://github.com/expo/expo/pull/46256) by [@jakex7](https://github.com/jakex7))
22
51
 
23
- ### 💡 Others
24
-
25
- - Native view config attributes now carry a `process` function that unwraps shared objects to their registry id, so callers can pass shared objects directly as view props instead of unwrapping them manually. ([#46212](https://github.com/expo/expo/pull/46212) by [@tsapeta](https://github.com/tsapeta))
26
-
27
52
  ## 56.0.12 — 2026-05-21
28
53
 
29
54
  ### 🐛 Bug fixes
@@ -119,7 +119,7 @@ Pod::Spec.new do |s|
119
119
  s.static_framework = true
120
120
  s.header_dir = 'ExpoModulesCore'
121
121
  s.source_files = 'ios/**/*.{h,m,mm,swift,cpp}', 'common/cpp/**/*.{h,cpp}'
122
- s.exclude_files = ['ios/Tests', 'ios/Worklets', 'ios/WorkletsAdapter']
122
+ s.exclude_files = ['ios/Tests', 'ios/Worklets', 'ios/WorkletsTests', 'ios/WorkletsAdapter']
123
123
  s.compiler_flags = compiler_flags
124
124
  s.private_header_files = ['ios/**/*+Private.h', 'ios/**/Swift.h']
125
125
  end
@@ -31,4 +31,11 @@ Pod::Spec.new do |s|
31
31
 
32
32
  s.source_files = 'ios/Worklets/**/*.{h,m,mm,swift,cpp}'
33
33
  s.private_header_files = 'ios/Worklets/**/*+Private.h'
34
+
35
+ s.test_spec 'Tests' do |test_spec|
36
+ # ExpoModulesCore requires React-hermes or React-jsc in tests, add ExpoModulesTestCore for the underlying dependencies
37
+ test_spec.dependency 'ExpoModulesTestCore'
38
+
39
+ test_spec.source_files = 'ios/WorkletsTests/**/*.{m,swift}'
40
+ end
34
41
  end
@@ -27,7 +27,7 @@ if (shouldIncludeCompose) {
27
27
  }
28
28
 
29
29
  group = 'host.exp.exponent'
30
- version = '56.0.14'
30
+ version = '56.0.16'
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.14"
97
+ versionName "56.0.16"
98
98
  buildConfigField "String", "EXPO_MODULES_CORE_VERSION", "\"${versionName}\""
99
99
  buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", "true"
100
100
 
@@ -128,6 +128,8 @@ abstract class ExpoComposeView<T : ComposeProps>(
128
128
  recomposeScope = currentRecomposeScope
129
129
  for (index in 0..<this.size) {
130
130
  val child = getChildAt(index) as? ExpoComposeView<*> ?: continue
131
+ // Hosting children render themselves via their own ComposeView; skip to avoid double-rendering.
132
+ if (child.shouldUseAndroidLayout) continue
131
133
  key(child) {
132
134
  with(composableScope ?: ComposableScope()) {
133
135
  with(child) {
@@ -143,6 +145,7 @@ abstract class ExpoComposeView<T : ComposeProps>(
143
145
  recomposeScope = currentRecomposeScope
144
146
  for (index in 0..<this.size) {
145
147
  val child = getChildAt(index) as? ExpoComposeView<*> ?: continue
148
+ if (child.shouldUseAndroidLayout) continue
146
149
  if (!filter(child)) {
147
150
  continue
148
151
  }
@@ -160,6 +163,7 @@ abstract class ExpoComposeView<T : ComposeProps>(
160
163
  fun Child(composableScope: ComposableScope, index: Int) {
161
164
  recomposeScope = currentRecomposeScope
162
165
  val child = getChildAt(index) as? ExpoComposeView<*> ?: return
166
+ if (child.shouldUseAndroidLayout) return
163
167
  key(child) {
164
168
  with(composableScope) {
165
169
  with(child) {
@@ -507,7 +511,8 @@ class ComposeFunctionHolder<Props : ComposeProps>(
507
511
  appContext: AppContext,
508
512
  override val name: String,
509
513
  private val composableContent: @Composable FunctionalComposableScope.(props: Props) -> Unit,
510
- override val props: Props
514
+ override val props: Props,
515
+ override val callbacksDefinition: CallbacksDefinition?
511
516
  ) : ExpoComposeView<Props>(context, appContext), ViewFunctionHolder {
512
517
  val propsMutableState = mutableStateOf(props)
513
518
 
@@ -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("updateStyleSizeImmediate", NativeStatePropsGetter::updateStyleSizeImmediate),
13
+ makeNativeMethod("updateViewSizeImmediate", 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
@@ -6,4 +6,10 @@ import com.facebook.yoga.annotations.DoNotStrip
6
6
  class NativeStatePropsGetter {
7
7
  // We can't use StateWrapper directly, as this class is not exposed
8
8
  external fun getStateProps(stateWrapper: Any): Map<String, Any?>?
9
+
10
+ // 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
+
13
+ // Synchronously flush a size update in the current frame.
14
+ external fun updateViewSizeImmediate(stateWrapper: Any, width: Double, height: Double)
9
15
  }
@@ -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
  }
@@ -462,9 +462,7 @@ public class ExpoAppDelegateSubscriberManager: NSObject {
462
462
  */
463
463
  @objc
464
464
  public static func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask {
465
- let deviceOrientationMask = allowedOrientations(for: UIDevice.current.userInterfaceIdiom)
466
- let universalOrientationMask = allowedOrientations(for: .unspecified)
467
- let infoPlistOrientations = deviceOrientationMask.isEmpty ? universalOrientationMask : deviceOrientationMask
465
+ let infoPlistOrientations = allowedOrientations(for: UIDevice.current.userInterfaceIdiom)
468
466
 
469
467
  let parsedSubscribers = ExpoAppDelegateSubscriberRepository.subscribers.filter {
470
468
  $0.responds(to: #selector(UIApplicationDelegate.application(_:supportedInterfaceOrientationsFor:)))
@@ -483,14 +481,28 @@ public class ExpoAppDelegateSubscriberManager: NSObject {
483
481
  }
484
482
 
485
483
  #if os(iOS)
486
- private func allowedOrientations(for userInterfaceIdiom: UIUserInterfaceIdiom) -> UIInterfaceOrientationMask {
487
- // For now only iPad-specific orientations are supported
488
- let deviceString = userInterfaceIdiom == .pad ? "~pad" : ""
489
- var mask: UIInterfaceOrientationMask = []
490
- guard let orientations = Bundle.main.infoDictionary?["UISupportedInterfaceOrientations\(deviceString)"] as? [String] else {
484
+ func allowedOrientations(
485
+ for userInterfaceIdiom: UIUserInterfaceIdiom,
486
+ infoDictionary: [String: Any]? = Bundle.main.infoDictionary
487
+ ) -> UIInterfaceOrientationMask {
488
+ // For now only iPad-specific orientations are supported. When the `~ipad` key is absent,
489
+ // fall back to the universal `UISupportedInterfaceOrientations` key, matching how UIKit
490
+ // resolves device-specific keys. We additionally fall back when `~ipad` is present but
491
+ // resolves to no orientations, treating a malformed entry as if it were absent.
492
+ if userInterfaceIdiom == .pad,
493
+ let mask = orientationMask(forKey: "UISupportedInterfaceOrientations~ipad", in: infoDictionary),
494
+ !mask.isEmpty {
491
495
  return mask
492
496
  }
497
+ return orientationMask(forKey: "UISupportedInterfaceOrientations", in: infoDictionary) ?? []
498
+ }
493
499
 
500
+ private func orientationMask(forKey key: String, in infoDictionary: [String: Any]?) -> UIInterfaceOrientationMask? {
501
+ guard let orientations = infoDictionary?[key] as? [String] else {
502
+ return nil
503
+ }
504
+
505
+ var mask: UIInterfaceOrientationMask = []
494
506
  for orientation in orientations {
495
507
  switch orientation {
496
508
  case "UIInterfaceOrientationPortrait":
@@ -529,8 +529,8 @@ public final class AppContext: NSObject, EXAppContextProtocol, @unchecked Sendab
529
529
  */
530
530
  @JavaScriptActor
531
531
  private func installModuleClasses(in runtime: JavaScriptRuntime) throws {
532
- let coreObject = runtime.global().getPropertyAsObject(EXGlobalCoreObjectPropertyName)
533
- let sharedObjectClass = coreObject.getPropertyAsObject("SharedObject")
532
+ let coreObject = try runtime.global().getPropertyAsObject(EXGlobalCoreObjectPropertyName)
533
+ let sharedObjectClass = try coreObject.getPropertyAsObject("SharedObject")
534
534
  let sharedObjectBaseProto = sharedObjectClass.getProperty("prototype")
535
535
 
536
536
  // Stored as JavaScriptValue (a class) because JavaScriptObject is ~Copyable
@@ -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
  }
@@ -218,7 +218,7 @@ public struct Conversions {
218
218
  memcpy(arrayBuffer.data(), baseAddress, data.count)
219
219
  }
220
220
  }
221
- let uint8ArrayCtor = runtime.global().getPropertyAsFunction("Uint8Array")
221
+ let uint8ArrayCtor = try runtime.global().getPropertyAsFunction("Uint8Array")
222
222
  return try uint8ArrayCtor.callAsConstructor(arrayBuffer.asValue())
223
223
  }
224
224
 
@@ -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
@@ -95,7 +95,7 @@ private func getBaseSharedType(_ appContext: AppContext, nativeType: AnySharedOb
95
95
 
96
96
  private func newBaseSharedObject(_ appContext: AppContext, nativeType: AnySharedObject.Type) throws -> JavaScriptObject? {
97
97
  let jsClass = try getBaseSharedType(appContext, nativeType: nativeType)
98
- let prototype = jsClass.asObject().getPropertyAsObject("prototype")
98
+ let prototype = try jsClass.asObject().getPropertyAsObject("prototype")
99
99
  return try appContext.runtime.createObject(prototype: prototype)
100
100
  }
101
101
 
@@ -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")
@@ -186,7 +186,7 @@ public class AsyncFunctionDefinition<Args, FirstArgType, ReturnType>: AnyAsyncFu
186
186
  guard let appContext else {
187
187
  throw Exceptions.AppContextLost()
188
188
  }
189
- let promise = JavaScriptPromise(try appContext.runtime)
189
+ let promise = try JavaScriptPromise(appContext.runtime)
190
190
  let promiseValue = promise.asValue()
191
191
 
192
192
  self.call(appContext, this: this, arguments: arguments) { [promise] result in
@@ -209,5 +209,3 @@ public class AsyncFunctionDefinition<Args, FirstArgType, ReturnType>: AnyAsyncFu
209
209
  return self
210
210
  }
211
211
  }
212
-
213
-
@@ -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
 
@@ -30,7 +30,7 @@ internal class ExpoRuntimeInstaller: EXJavaScriptRuntimeManager {
30
30
  */
31
31
  @JavaScriptActor
32
32
  internal func installExpoModulesHostObject() throws {
33
- let coreObject = runtime.global().getPropertyAsObject(EXGlobalCoreObjectPropertyName)
33
+ let coreObject = try runtime.global().getPropertyAsObject(EXGlobalCoreObjectPropertyName)
34
34
 
35
35
  if coreObject.hasProperty("modules") {
36
36
  // Host object already installed
@@ -16,6 +16,7 @@
16
16
  @compatibility_alias UIWindow NSWindow;
17
17
  @compatibility_alias UIHostingController NSHostingController;
18
18
  @compatibility_alias UIImage NSImage;
19
+ @compatibility_alias UIImageView NSImageView;
19
20
 
20
21
  #ifndef UIApplication
21
22
  @compatibility_alias UIApplication NSApplication;
@@ -14,6 +14,7 @@ public typealias UIHostingController = NSHostingController
14
14
  public typealias UIViewRepresentable = NSViewRepresentable
15
15
  public typealias UILabel = NSLabel
16
16
  public typealias UIImage = NSImage
17
+ public typealias UIImageView = NSImageView
17
18
  public typealias UIPasteboard = NSPasteboard
18
19
 
19
20
  extension UIApplication {
@@ -28,6 +28,10 @@ internal struct DynamicSerializableType: AnyDynamicType {
28
28
  }
29
29
  }
30
30
  guard let jsSerializable else {
31
+ // Adapter not linked → the extractor returns nil for every value; give an actionable error.
32
+ if WorkletsProviderRegistry.shared == nil {
33
+ throw WorkletsNotInstalledException()
34
+ }
31
35
  throw NotSerializableException(innerType)
32
36
  }
33
37
  return Serializable(jsSerializable)
@@ -46,3 +46,13 @@ internal final class NotWorkletException: GenericException<SerializableValueType
46
46
  "Expected Serializable of type Worklet but got \(param)"
47
47
  }
48
48
  }
49
+
50
+ /// Thrown when a worklet operation is attempted but the `react-native-worklets` native
51
+ /// adapter isn't linked (its provider is `nil`). Replaces misleading lower-level errors.
52
+ internal final class WorkletsNotInstalledException: Exception, @unchecked Sendable {
53
+ override var reason: String {
54
+ "This feature requires `react-native-worklets`, but its native adapter isn't linked — usually " +
55
+ "because the package isn't installed (it's an optional peer dependency of libraries like Expo UI " +
56
+ "and Reanimated). Install it with `npx expo install react-native-worklets`, then rebuild the app."
57
+ }
58
+ }
@@ -0,0 +1,27 @@
1
+ // Copyright 2025-present 650 Industries. All rights reserved.
2
+
3
+ import Testing
4
+
5
+ import ExpoModulesCore
6
+ import ExpoModulesWorklets
7
+
8
+ /**
9
+ Runtime-level checks for the worklets integration wiring.
10
+
11
+ Note: these run under `use_expo_modules_tests!`, which does not link companion pods, so the
12
+ worklets adapter — and therefore `WorkletsProviderRegistry.shared` — is intentionally absent
13
+ here (the same state as an app without `react-native-worklets`). Whether the adapter is
14
+ correctly autolinked is guarded by the JS test `WorkletsAdapterAutolinking-test.ts`; the
15
+ consumer-side behaviour when it's missing is covered by `WorkletsNotInstalledTests`.
16
+ */
17
+ @Suite("WorkletRuntimeIntegration", .serialized)
18
+ struct WorkletRuntimeIntegrationTests {
19
+ @Test
20
+ func `UI worklet runtime factory registers on the app context`() {
21
+ // `WorkletIntegration.register()` installs the core-side hook (normally invoked via +load)
22
+ // that turns a worklet runtime pointer into a usable runtime. Guards the factory wiring onto
23
+ // AppContext; it does not require the adapter to be linked.
24
+ WorkletIntegration.register()
25
+ #expect(AppContext.uiRuntimeFactory != nil)
26
+ }
27
+ }
@@ -0,0 +1,33 @@
1
+ // Copyright 2025-present 650 Industries. All rights reserved.
2
+
3
+ import Testing
4
+
5
+ @testable import ExpoModulesCore
6
+ @testable import ExpoModulesWorklets
7
+
8
+ /**
9
+ The `use_expo_modules_tests!` harness does not link companion pods, so the worklets adapter
10
+ never registers `WorkletsProviderRegistry.shared`. That nil-provider state is exactly an app
11
+ where `react-native-worklets` isn't installed — so this is the right place to assert that a
12
+ worklet operation fails with the actionable `WorkletsNotInstalledException` rather than a
13
+ misleading lower-level error (e.g. "not an instance of Worklet").
14
+ */
15
+ @Suite("WorkletsNotInstalled", .serialized)
16
+ @JavaScriptActor
17
+ struct WorkletsNotInstalledTests {
18
+ @Test
19
+ func `converting a worklet argument reports that worklets are not installed`() throws {
20
+ // Only meaningful while the adapter is unlinked (the default in this test harness).
21
+ try #require(WorkletsProviderRegistry.shared == nil)
22
+
23
+ let appContext = AppContext.create()
24
+ let runtime = try appContext.runtime
25
+ // Any JS value works — with the adapter unlinked, extraction returns nil for everything.
26
+ let jsValue = try runtime.eval("42")
27
+ let dynamicType = DynamicSerializableType(innerType: Worklet.self)
28
+
29
+ #expect(throws: WorkletsNotInstalledException.self) {
30
+ _ = try dynamicType.cast(jsValue: jsValue, appContext: appContext)
31
+ }
32
+ }
33
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-modules-core",
3
- "version": "56.0.14",
3
+ "version": "56.0.16",
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.7",
49
+ "@expo/expo-modules-macros-plugin": "0.2.2",
50
+ "expo-modules-jsi": "~56.0.9",
51
51
  "invariant": "^2.2.4"
52
52
  },
53
53
  "peerDependencies": {
@@ -64,9 +64,9 @@
64
64
  "@testing-library/react-native": "^13.3.0",
65
65
  "@types/react": "~19.2.0",
66
66
  "@types/invariant": "^2.2.33",
67
- "expo-module-scripts": "56.0.2"
67
+ "expo-module-scripts": "56.0.3"
68
68
  },
69
- "gitHead": "a7adc95c1747db1e92655feba56d0e62660098db",
69
+ "gitHead": "b1e94a5c1c5b19472a42ca25752a3533699bc46a",
70
70
  "scripts": {
71
71
  "build": "expo-module build",
72
72
  "clean": "expo-module clean",
package/spm.config.json CHANGED
@@ -46,6 +46,7 @@
46
46
  "headerPattern": "**/*.h",
47
47
  "exclude": [
48
48
  "Tests/**",
49
+ "WorkletsTests/**",
49
50
  "Worklets/**",
50
51
  "WorkletsAdapter/**"
51
52
  ],
@@ -87,6 +88,7 @@
87
88
  "exclude": [
88
89
  "JSI/**",
89
90
  "Tests/**",
91
+ "WorkletsTests/**",
90
92
  "BridgeModule/**",
91
93
  "Views/**",
92
94
  "Worklets/**",