expo-modules-core 57.0.11 → 57.0.12

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,13 @@
10
10
 
11
11
  ### 💡 Others
12
12
 
13
+ ## 57.0.12 — 2026-08-20
14
+
15
+ ### 🐛 Bug fixes
16
+
17
+ - [iOS] Fixed an infinite main-thread layout loop (frozen UI, watchdog kill on backgrounding) when a SwiftUI host with `matchContents` and Yoga persistently disagree on the content size, e.g. with the Button Shapes accessibility setting enabled. Synchronous size commits are now budgeted per run-loop turn; over-budget updates are coalesced on the view and committed asynchronously on the next turn, and no-op size updates no longer dirty the layout. ([#48058](https://github.com/expo/expo/issues/48058), [#48059](https://github.com/expo/expo/pull/48059) by [@focux](https://github.com/focux))
18
+ - [iOS] Fixed the `ExpoModulesProvider` lookup missing the generated class when the app `name` starts with a digit, which registered no native modules and left release builds on a blank screen. ([#48793](https://github.com/expo/expo/pull/48793) by [@expo-bot](https://github.com/expo-bot))
19
+
13
20
  ## 57.0.11 — 2026-08-14
14
21
 
15
22
  ### 🎉 New features
@@ -27,7 +27,7 @@ if (shouldIncludeCompose) {
27
27
  }
28
28
 
29
29
  group = 'host.exp.exponent'
30
- version = '57.0.11'
30
+ version = '57.0.12'
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 "57.0.11"
97
+ versionName "57.0.12"
98
98
  buildConfigField "String", "EXPO_MODULES_CORE_VERSION", "\"${versionName}\""
99
99
  buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", "true"
100
100
 
@@ -70,15 +70,19 @@ public:
70
70
  bool changedStyle = false;
71
71
 
72
72
  if (!isnan(styleWidth)) {
73
- style.setDimension(facebook::yoga::Dimension::Width,
74
- facebook::yoga::StyleSizeLength::points(styleWidth));
75
- changedStyle = true;
73
+ auto expectedWidth = facebook::yoga::StyleSizeLength::points(styleWidth);
74
+ if (style.dimension(facebook::yoga::Dimension::Width) != expectedWidth) {
75
+ style.setDimension(facebook::yoga::Dimension::Width, expectedWidth);
76
+ changedStyle = true;
77
+ }
76
78
  }
77
79
 
78
80
  if (!isnan(styleHeight)) {
79
- style.setDimension(facebook::yoga::Dimension::Height,
80
- facebook::yoga::StyleSizeLength::points(styleHeight));
81
- changedStyle = true;
81
+ auto expectedHeight = facebook::yoga::StyleSizeLength::points(styleHeight);
82
+ if (style.dimension(facebook::yoga::Dimension::Height) != expectedHeight) {
83
+ style.setDimension(facebook::yoga::Dimension::Height, expectedHeight);
84
+ changedStyle = true;
85
+ }
82
86
  }
83
87
 
84
88
  // Update yoga props and dirty layout if we changed the style
@@ -726,9 +726,9 @@ public final class AppContext: NSObject, EXAppContextProtocol, @unchecked Sendab
726
726
  public static func modulesProvider(withName providerName: String = "ExpoModulesProvider") -> ModulesProvider {
727
727
  // [0] When ExpoModulesCore is built as separated framework/module,
728
728
  // we should explicitly load main bundle's `ExpoModulesProvider` class.
729
- // CFBundleExecutable is tried first: it equals $(PRODUCT_NAME:c99extidentifier) and
730
- // directly matches the Swift module name. CFBundleName is kept as a fallback for the
731
- // uncommon case where both values are identical valid identifiers.
729
+ // CFBundleExecutable is tried first: it is the product name, from which the Swift module
730
+ // name is derived. CFBundleName is kept as a fallback for the uncommon case where both
731
+ // values are identical valid identifiers.
732
732
  let mainBundleNames = [
733
733
  Bundle.main.infoDictionary?["CFBundleExecutable"],
734
734
  Bundle.main.infoDictionary?["CFBundleName"]
@@ -765,12 +765,27 @@ public final class AppContext: NSObject, EXAppContextProtocol, @unchecked Sendab
765
765
 
766
766
  internal static func moduleProviderClassNames(withName providerName: String, bundleNames: [String]) -> [String] {
767
767
  var seen = Set<String>()
768
- return bundleNames.compactMap { bundleName in
768
+ return bundleNames.flatMap { [$0, c99ExtendedIdentifier($0)] }.compactMap { bundleName in
769
769
  let candidate = "\(bundleName).\(providerName)"
770
770
  return seen.insert(candidate).inserted ? candidate : nil
771
771
  }
772
772
  }
773
773
 
774
+ /**
775
+ Applies the same substitution as Xcode's `:c99extidentifier` string operator, which derives the
776
+ default `PRODUCT_MODULE_NAME` (and thus the Swift module name) from `PRODUCT_NAME`. The bundle
777
+ names above are the raw product name, so the two differ whenever the product name is not a valid
778
+ C99 identifier — most commonly when the app's `name` starts with a digit, where a product name of
779
+ `123myapp` compiles into the Swift module `_23myapp`.
780
+ */
781
+ internal static func c99ExtendedIdentifier(_ name: String) -> String {
782
+ var characters = name.map { $0.isLetter || $0.isNumber || $0 == "_" ? $0 : "_" }
783
+ if let first = characters.first, first.isNumber {
784
+ characters[0] = "_"
785
+ }
786
+ return String(characters)
787
+ }
788
+
774
789
  public func reloadAppAsync(_ reason: String = "Reload from appContext") {
775
790
  if moduleRegistry.has(moduleWithName: "ExpoGo") {
776
791
  NotificationCenter.default.post(name: NSNotification.Name(rawValue: "EXReloadActiveAppRequest"), object: nil)
@@ -54,9 +54,9 @@
54
54
 
55
55
  - (void)viewDidUpdateProps NS_SWIFT_UI_ACTOR;
56
56
 
57
- - (void)setShadowNodeSize:(float)width height:(float)height;
57
+ - (void)setShadowNodeSize:(float)width height:(float)height NS_SWIFT_UI_ACTOR;
58
58
 
59
- - (void)setStyleSize:(nullable NSNumber *)width height:(nullable NSNumber *)height;
59
+ - (void)setStyleSize:(nullable NSNumber *)width height:(nullable NSNumber *)height NS_SWIFT_UI_ACTOR;
60
60
 
61
61
  - (BOOL)supportsPropWithName:(nonnull NSString *)name;
62
62
 
@@ -8,13 +8,86 @@
8
8
  #import <ExpoModulesCore/ExpoViewComponentDescriptor.h>
9
9
  #import <ExpoModulesCore/EXJSIConversions.h>
10
10
 
11
+ #import <React/RCTAssert.h>
11
12
  #import <React/RCTComponentViewFactory.h>
12
13
  #import <react/renderer/componentregistry/ComponentDescriptorProvider.h>
13
14
 
15
+ #include <cmath>
16
+ #include <optional>
17
+
14
18
  using namespace expo;
15
19
 
16
20
  namespace {
17
21
 
22
+ #if REACT_NATIVE_TARGET_VERSION >= 82
23
+
24
+ /**
25
+ How many synchronous (`unstable_Immediate`) size commits a single view may issue per
26
+ main-queue turn. The synchronous mode lets a self-sizing view adopt its measured content
27
+ size within the current frame, but it is only safe while the measured size and the Yoga
28
+ layout that results from committing it converge. When they persistently disagree (e.g. the
29
+ Button Shapes accessibility setting changes how SwiftUI content re-measures under the frame
30
+ it was granted), every synchronous commit re-triggers a measurement in the same run-loop
31
+ turn and the feedback becomes an unbounded main-thread loop that freezes the app until the
32
+ watchdog kills it. Updates over the budget are coalesced locally and committed
33
+ asynchronously on the next turn, so a non-convergent layout settles across frames instead.
34
+ */
35
+ constexpr NSUInteger kImmediateSizeUpdateBudgetPerTurn = 2;
36
+
37
+ /**
38
+ Size deltas below this threshold (in points) are considered noise (e.g. rounding jitter
39
+ between the pixel grid and SwiftUI's floating point sizes) and are not re-committed.
40
+ */
41
+ constexpr float kSizeEpsilon = 0.05f;
42
+
43
+ struct SizePair {
44
+ float width;
45
+ float height;
46
+ };
47
+
48
+ bool nearlyEqualSize(float a, float b)
49
+ {
50
+ if (std::isnan(a) || std::isnan(b)) {
51
+ return std::isnan(a) && std::isnan(b);
52
+ }
53
+ if (a == b) {
54
+ return true;
55
+ }
56
+ return std::fabs(a - b) <= kSizeEpsilon;
57
+ }
58
+
59
+ bool nearlyEqualSize(const SizePair &a, const SizePair &b)
60
+ {
61
+ return nearlyEqualSize(a.width, b.width) && nearlyEqualSize(a.height, b.height);
62
+ }
63
+
64
+ /**
65
+ A pending size update. Each channel maps to the corresponding pair of `ExpoViewState`
66
+ fields; merging keeps the latest value per channel so deferred updates cannot erase
67
+ the other channel (unlike replacing the whole state).
68
+ */
69
+ struct SizeStatePatch {
70
+ std::optional<SizePair> shadowDimensions;
71
+ std::optional<SizePair> styleDimensions;
72
+
73
+ bool empty() const
74
+ {
75
+ return !shadowDimensions && !styleDimensions;
76
+ }
77
+
78
+ void merge(const SizeStatePatch &newer)
79
+ {
80
+ if (newer.shadowDimensions) {
81
+ shadowDimensions = newer.shadowDimensions;
82
+ }
83
+ if (newer.styleDimensions) {
84
+ styleDimensions = newer.styleDimensions;
85
+ }
86
+ }
87
+ };
88
+
89
+ #endif // REACT_NATIVE_TARGET_VERSION >= 82
90
+
18
91
  id convertFollyDynamicToId(const folly::dynamic &dyn)
19
92
  {
20
93
  // I could imagine an implementation which avoids copies by wrapping the
@@ -79,6 +152,14 @@ static std::unordered_map<std::string, ExpoViewComponentDescriptor<>::Flavor> _c
79
152
 
80
153
  @implementation ExpoFabricViewObjC {
81
154
  ExpoViewShadowNode<>::ConcreteState::Shared _state;
155
+ #if REACT_NATIVE_TARGET_VERSION >= 82
156
+ NSUInteger _immediateSizeUpdatesInTurn;
157
+ BOOL _sizeGuardResetScheduled;
158
+ uint64_t _sizeGuardGeneration;
159
+ std::optional<SizePair> _lastShadowSizeRequestInTurn;
160
+ std::optional<SizePair> _lastStyleSizeRequestInTurn;
161
+ SizeStatePatch _deferredSizePatch;
162
+ #endif
82
163
  }
83
164
 
84
165
  - (instancetype)initWithFrame:(CGRect)frame
@@ -170,15 +251,141 @@ static std::unordered_map<std::string, ExpoViewComponentDescriptor<>::Flavor> _c
170
251
 
171
252
  - (void)setShadowNodeSize:(float)width height:(float)height
172
253
  {
173
- if (_state) {
174
254
  #if REACT_NATIVE_TARGET_VERSION >= 82
175
- _state->updateState(ExpoViewState(width,height), react::EventQueue::UpdateMode::unstable_Immediate);
255
+ SizeStatePatch patch;
256
+ patch.shadowDimensions = SizePair{
257
+ width >= 0 ? width : std::numeric_limits<float>::quiet_NaN(),
258
+ height >= 0 ? height : std::numeric_limits<float>::quiet_NaN()
259
+ };
260
+ [self submitSizePatch:patch];
176
261
  #else
177
- _state->updateState(ExpoViewState(width,height));
262
+ if (_state) {
263
+ _state->updateState(ExpoViewState(width, height));
264
+ }
178
265
  #endif
266
+ }
267
+
268
+ #if REACT_NATIVE_TARGET_VERSION >= 82
269
+
270
+ - (void)submitSizePatch:(const SizeStatePatch &)patch
271
+ {
272
+ RCTAssertMainQueue();
273
+
274
+ if (!_state) {
275
+ return;
276
+ }
277
+
278
+ // Transient per-turn dedup: prevents re-entrant same-size requests from
279
+ // committing again before `updateState` returns. Cleared every turn, so it
280
+ // can never go stale across turns.
281
+ if (patch.shadowDimensions) {
282
+ if (_lastShadowSizeRequestInTurn && nearlyEqualSize(*_lastShadowSizeRequestInTurn, *patch.shadowDimensions)) {
283
+ return;
284
+ }
285
+ _lastShadowSizeRequestInTurn = *patch.shadowDimensions;
286
+ }
287
+ if (patch.styleDimensions) {
288
+ if (_lastStyleSizeRequestInTurn && nearlyEqualSize(*_lastStyleSizeRequestInTurn, *patch.styleDimensions)) {
289
+ return;
290
+ }
291
+ _lastStyleSizeRequestInTurn = *patch.styleDimensions;
292
+ }
293
+
294
+ if (!_sizeGuardResetScheduled) {
295
+ _sizeGuardResetScheduled = YES;
296
+ const uint64_t generation = _sizeGuardGeneration;
297
+ __weak ExpoFabricViewObjC *weakSelf = self;
298
+
299
+ dispatch_async(dispatch_get_main_queue(), ^{
300
+ ExpoFabricViewObjC *strongSelf = weakSelf;
301
+ if (strongSelf == nil || strongSelf->_sizeGuardGeneration != generation) {
302
+ return;
303
+ }
304
+ strongSelf->_sizeGuardResetScheduled = NO;
305
+ strongSelf->_immediateSizeUpdatesInTurn = 0;
306
+ strongSelf->_lastShadowSizeRequestInTurn.reset();
307
+ strongSelf->_lastStyleSizeRequestInTurn.reset();
308
+
309
+ SizeStatePatch pending = strongSelf->_deferredSizePatch;
310
+ strongSelf->_deferredSizePatch = {};
311
+
312
+ if (!pending.empty()) {
313
+ // Deliberately entered into React Native's event queue only now: an
314
+ // `unstable_Immediate` update from any other view flushes the whole
315
+ // shared queue synchronously, which would pull an "asynchronous"
316
+ // update enqueued during the previous turn right back into the
317
+ // feedback loop this guard is breaking.
318
+ [strongSelf dispatchSizePatch:pending updateMode:react::EventQueue::UpdateMode::Asynchronous];
319
+ }
320
+ });
321
+ }
322
+
323
+ if (_immediateSizeUpdatesInTurn < kImmediateSizeUpdateBudgetPerTurn) {
324
+ _immediateSizeUpdatesInTurn++;
325
+ [self dispatchSizePatch:patch updateMode:react::EventQueue::UpdateMode::unstable_Immediate];
326
+ } else {
327
+ _deferredSizePatch.merge(patch);
179
328
  }
180
329
  }
181
330
 
331
+ - (void)dispatchSizePatch:(const SizeStatePatch &)patch updateMode:(react::EventQueue::UpdateMode)updateMode
332
+ {
333
+ const auto state = _state;
334
+ if (!state) {
335
+ return;
336
+ }
337
+
338
+ state->updateState(
339
+ [patch](const ExpoViewState &oldData) -> ExpoViewShadowNode<>::ConcreteState::SharedData {
340
+ ExpoViewState newData = oldData;
341
+ bool changed = false;
342
+
343
+ if (patch.shadowDimensions &&
344
+ !(nearlyEqualSize(oldData._width, patch.shadowDimensions->width) &&
345
+ nearlyEqualSize(oldData._height, patch.shadowDimensions->height))) {
346
+ newData._width = patch.shadowDimensions->width;
347
+ newData._height = patch.shadowDimensions->height;
348
+ changed = true;
349
+ }
350
+ if (patch.styleDimensions &&
351
+ !(nearlyEqualSize(oldData._styleWidth, patch.styleDimensions->width) &&
352
+ nearlyEqualSize(oldData._styleHeight, patch.styleDimensions->height))) {
353
+ newData._styleWidth = patch.styleDimensions->width;
354
+ newData._styleHeight = patch.styleDimensions->height;
355
+ changed = true;
356
+ }
357
+
358
+ // Returning nullptr cancels the commit before any layout work happens,
359
+ // so a no-op size update cannot dirty the tree.
360
+ return changed ? std::make_shared<const ExpoViewState>(newData) : nullptr;
361
+ },
362
+ updateMode);
363
+ }
364
+
365
+ - (void)resetSizeUpdateGuard
366
+ {
367
+ _sizeGuardGeneration++;
368
+ _immediateSizeUpdatesInTurn = 0;
369
+ _sizeGuardResetScheduled = NO;
370
+ _lastShadowSizeRequestInTurn.reset();
371
+ _lastStyleSizeRequestInTurn.reset();
372
+ _deferredSizePatch = {};
373
+ }
374
+
375
+ - (void)prepareForRecycle
376
+ {
377
+ [super prepareForRecycle];
378
+ [self resetSizeUpdateGuard];
379
+ }
380
+
381
+ - (void)invalidate
382
+ {
383
+ [super invalidate];
384
+ [self resetSizeUpdateGuard];
385
+ }
386
+
387
+ #endif // REACT_NATIVE_TARGET_VERSION >= 82
388
+
182
389
  - (BOOL)supportsPropWithName:(nonnull NSString *)name
183
390
  {
184
391
  // Implemented in `ExpoFabricView.swift`
@@ -187,16 +394,20 @@ static std::unordered_map<std::string, ExpoViewComponentDescriptor<>::Flavor> _c
187
394
 
188
395
  - (void)setStyleSize:(nullable NSNumber *)width height:(nullable NSNumber *)height
189
396
  {
190
- if (_state) {
191
- float widthValue = width ? [width floatValue] : std::numeric_limits<float>::quiet_NaN();
192
- float heightValue = height ? [height floatValue] : std::numeric_limits<float>::quiet_NaN();
397
+ float widthValue = width ? [width floatValue] : std::numeric_limits<float>::quiet_NaN();
398
+ float heightValue = height ? [height floatValue] : std::numeric_limits<float>::quiet_NaN();
193
399
  #if REACT_NATIVE_TARGET_VERSION >= 82
194
- // synchronous update is only available in React Native 0.82 and above
195
- _state->updateState(expo::ExpoViewState::withStyleDimensions(widthValue, heightValue), react::EventQueue::UpdateMode::unstable_Immediate);
400
+ SizeStatePatch patch;
401
+ patch.styleDimensions = SizePair{
402
+ widthValue >= 0 ? widthValue : std::numeric_limits<float>::quiet_NaN(),
403
+ heightValue >= 0 ? heightValue : std::numeric_limits<float>::quiet_NaN()
404
+ };
405
+ [self submitSizePatch:patch];
196
406
  #else
407
+ if (_state) {
197
408
  _state->updateState(expo::ExpoViewState::withStyleDimensions(widthValue, heightValue));
198
- #endif
199
409
  }
410
+ #endif
200
411
  }
201
412
 
202
413
  #pragma mark - Component registration
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-modules-core",
3
- "version": "57.0.11",
3
+ "version": "57.0.12",
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.6.1",
50
- "expo-modules-jsi": "~57.0.4",
50
+ "expo-modules-jsi": "~57.0.5",
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": "125b8d4472432db3dc4ae047730432d7bd5bd30c",
69
+ "gitHead": "499024a441d67293aa60afcbe02051999a54f1bb",
70
70
  "scripts": {
71
71
  "build": "expo-module build",
72
72
  "clean": "expo-module clean",