expo-modules-core 57.0.10 → 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 +16 -0
- package/android/build.gradle +2 -2
- package/build/hooks/useReleasingSharedObject.d.ts.map +1 -1
- package/build/hooks/useReleasingSharedObjectWithLifecycle.d.ts +49 -0
- package/build/hooks/useReleasingSharedObjectWithLifecycle.d.ts.map +1 -0
- package/build/index.d.ts +1 -0
- package/build/index.d.ts.map +1 -1
- package/common/cpp/fabric/ExpoViewComponentDescriptor.h +10 -6
- package/ios/Core/AppContext.swift +19 -4
- package/ios/Core/Views/SwiftUI/SwiftUIHostingView.swift +8 -0
- package/ios/Core/Views/SwiftUI/SystemMenuTouchGate.swift +127 -0
- package/ios/Fabric/ExpoFabricViewObjC.h +2 -2
- package/ios/Fabric/ExpoFabricViewObjC.mm +220 -9
- package/ios/Utilities/SceneGeometry.swift +85 -0
- package/package.json +3 -3
- package/prebuilds/output/debug/xcframeworks/ExpoModulesCore.tar.gz +0 -0
- package/prebuilds/output/debug/xcframeworks/ExpoModulesWorklets.tar.gz +0 -0
- package/prebuilds/output/release/xcframeworks/ExpoModulesCore.tar.gz +0 -0
- package/prebuilds/output/release/xcframeworks/ExpoModulesWorklets.tar.gz +0 -0
- package/src/hooks/useReleasingSharedObject.ts +7 -52
- package/src/hooks/useReleasingSharedObjectWithLifecycle.ts +181 -0
- package/src/index.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,20 @@
|
|
|
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
|
+
|
|
20
|
+
## 57.0.11 — 2026-08-14
|
|
21
|
+
|
|
22
|
+
### 🎉 New features
|
|
23
|
+
|
|
24
|
+
- [iOS] Added `SceneGeometry.foregroundScene()` for resolving the foregrounded scene without a view. ([#48318](https://github.com/expo/expo/pull/48318) by [@alanjhughes](https://github.com/alanjhughes))
|
|
25
|
+
- [iOS] Added `SceneGeometry` for reading bounds, safe area, display scale and interface orientation from the scene a view belongs to. ([#48168](https://github.com/expo/expo/pull/48168) by [@alanjhughes](https://github.com/alanjhughes))
|
|
26
|
+
|
|
13
27
|
## 57.0.10 — 2026-08-06
|
|
14
28
|
|
|
15
29
|
### 💡 Others
|
|
@@ -25,6 +39,7 @@
|
|
|
25
39
|
|
|
26
40
|
### 🐛 Bug fixes
|
|
27
41
|
|
|
42
|
+
- [iOS] Fixed the tap that closes a SwiftUI menu also pressing the React Native view underneath it. ([#48419](https://github.com/expo/expo/issues/48419) by [@bohdanstefaniuk](https://github.com/bohdanstefaniuk)) ([#48463](https://github.com/expo/expo/pull/48463) by [@intergalacticspacehighway](https://github.com/intergalacticspacehighway))
|
|
28
43
|
- [Android] Fixed hosted Compose views missing layout after reattachment or in-place configuration changes. ([#48370](https://github.com/expo/expo/issues/48370) by [@lujjjh](https://github.com/lujjjh))
|
|
29
44
|
- [iOS] Fixed infinite recursion and `EXC_BAD_ACCESS` when encoding native `Date` values to JavaScript. ([#48239](https://github.com/expo/expo/issues/48239), [#48240](https://github.com/expo/expo/pull/48240) by [@samuelcorsan](https://github.com/samuelcorsan))
|
|
30
45
|
- [iOS] Fixed a stack overflow crash when converting to JavaScript a `Convertible` value that keeps the default `convertResult` implementation, such as `CMTime`. The value now converts to `undefined` and logs a warning. ([#48266](https://github.com/expo/expo/pull/48266) by [@tsapeta](https://github.com/tsapeta))
|
|
@@ -94,6 +109,7 @@ _This version does not introduce any user-facing changes._
|
|
|
94
109
|
- [iOS] `@ExpoModule` now synthesizes the module's JavaScript name from the class name or its `@ExpoModule("CustomName")` argument, so a `Name(…)` definition entry is no longer required. ([#46938](https://github.com/expo/expo/pull/46938) by [@tsapeta](https://github.com/tsapeta))
|
|
95
110
|
- [iOS] Added `SharedObject.native(from:)` that recovers the native shared object paired with a JavaScript object, with a generic overload that returns the concrete subclass directly. ([#47054](https://github.com/expo/expo/pull/47054) by [@tsapeta](https://github.com/tsapeta))
|
|
96
111
|
- [iOS] Added `Module.emit` that sends an event directly to the module's own JavaScript object, mirroring `SharedObject.emit`. Both now share an `EventEmitter` protocol. ([#46555](https://github.com/expo/expo/pull/46555) by [@tsapeta](https://github.com/tsapeta))
|
|
112
|
+
- Add `useReleasingSharedObjectWithLifecycle` hook. ([#46494](https://github.com/expo/expo/pull/46494) by [@behenate](https://github.com/behenate))
|
|
97
113
|
|
|
98
114
|
### 🐛 Bug fixes
|
|
99
115
|
|
package/android/build.gradle
CHANGED
|
@@ -27,7 +27,7 @@ if (shouldIncludeCompose) {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
group = 'host.exp.exponent'
|
|
30
|
-
version = '57.0.
|
|
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.
|
|
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
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useReleasingSharedObject.d.ts","sourceRoot":"","sources":["../../src/hooks/useReleasingSharedObject.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,OAAO,CAAC;AAG5C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iCAAiC,CAAC;AAEpE;;GAEG;AACH,wBAAgB,wBAAwB,CAAC,aAAa,SAAS,YAAY,EACzE,OAAO,EAAE,MAAM,aAAa,EAC5B,YAAY,EAAE,cAAc,GAC3B,aAAa,
|
|
1
|
+
{"version":3,"file":"useReleasingSharedObject.d.ts","sourceRoot":"","sources":["../../src/hooks/useReleasingSharedObject.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,OAAO,CAAC;AAG5C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iCAAiC,CAAC;AAEpE;;GAEG;AACH,wBAAgB,wBAAwB,CAAC,aAAa,SAAS,YAAY,EACzE,OAAO,EAAE,MAAM,aAAa,EAC5B,YAAY,EAAE,cAAc,GAC3B,aAAa,CAOf"}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { DependencyList } from 'react';
|
|
2
|
+
import type { SharedObject } from '../ts-declarations/SharedObject';
|
|
3
|
+
export type ReleasingSharedObjectLifecycleContext = {
|
|
4
|
+
/**
|
|
5
|
+
* The dependency values from the last committed object lifecycle decision.
|
|
6
|
+
*/
|
|
7
|
+
previousDependencies: DependencyList;
|
|
8
|
+
/**
|
|
9
|
+
* The dependency values from the current render.
|
|
10
|
+
*/
|
|
11
|
+
dependencies: DependencyList;
|
|
12
|
+
};
|
|
13
|
+
export type ReleasingSharedObjectLifecycle<TSharedObject extends SharedObject> = {
|
|
14
|
+
/**
|
|
15
|
+
* Creates the shared object when the hook initializes or when `shouldRecreate` returns `true`.
|
|
16
|
+
*/
|
|
17
|
+
factory: () => TSharedObject;
|
|
18
|
+
/**
|
|
19
|
+
* Called during render when dependencies change to decide whether to replace the object.
|
|
20
|
+
* Return `false` to keep the current object and handle the dependency change with the `update` function.
|
|
21
|
+
* When omitted or `true`, dependency changes recreate the object, matching `useReleasingSharedObject`.
|
|
22
|
+
*
|
|
23
|
+
* Must be a pure function with no side effects — it is called during the render phase and
|
|
24
|
+
* React may invoke it more than once with the same inputs.
|
|
25
|
+
*/
|
|
26
|
+
shouldRecreate?: (object: TSharedObject, context: ReleasingSharedObjectLifecycleContext) => boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Called after commit when dependencies changed and `shouldRecreate` returned `false`.
|
|
29
|
+
* Has no effect unless `shouldRecreate` is provided and returns `false` for the changed
|
|
30
|
+
* dependencies.
|
|
31
|
+
*
|
|
32
|
+
* If the returned `Promise` rejects, the error is logged with `console.error`. Handle errors
|
|
33
|
+
* inside `update` if specific error handling is needed.
|
|
34
|
+
*
|
|
35
|
+
* If a subsequent dependency change or unmount requires the object to be released while an
|
|
36
|
+
* async update is still in-flight, the release is deferred until the update settles.
|
|
37
|
+
*/
|
|
38
|
+
update?: (object: TSharedObject, context: ReleasingSharedObjectLifecycleContext) => void | Promise<void>;
|
|
39
|
+
/**
|
|
40
|
+
* Releases an object after it has been replaced or when the component unmounts.
|
|
41
|
+
* When omitted, the object's `release` method is called.
|
|
42
|
+
*/
|
|
43
|
+
release?: (object: TSharedObject) => void;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Returns a shared object, delegating dependency changes to lifecycle callbacks.
|
|
47
|
+
*/
|
|
48
|
+
export declare function useReleasingSharedObjectWithLifecycle<TSharedObject extends SharedObject>(lifecycle: ReleasingSharedObjectLifecycle<TSharedObject>, dependencies: DependencyList): TSharedObject;
|
|
49
|
+
//# sourceMappingURL=useReleasingSharedObjectWithLifecycle.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useReleasingSharedObjectWithLifecycle.d.ts","sourceRoot":"","sources":["../../src/hooks/useReleasingSharedObjectWithLifecycle.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,OAAO,CAAC;AAG5C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iCAAiC,CAAC;AAEpE,MAAM,MAAM,qCAAqC,GAAG;IAClD;;OAEG;IACH,oBAAoB,EAAE,cAAc,CAAC;IAErC;;OAEG;IACH,YAAY,EAAE,cAAc,CAAC;CAC9B,CAAC;AAEF,MAAM,MAAM,8BAA8B,CAAC,aAAa,SAAS,YAAY,IAAI;IAC/E;;OAEG;IACH,OAAO,EAAE,MAAM,aAAa,CAAC;IAE7B;;;;;;;OAOG;IACH,cAAc,CAAC,EAAE,CACf,MAAM,EAAE,aAAa,EACrB,OAAO,EAAE,qCAAqC,KAC3C,OAAO,CAAC;IAEb;;;;;;;;;;OAUG;IACH,MAAM,CAAC,EAAE,CACP,MAAM,EAAE,aAAa,EACrB,OAAO,EAAE,qCAAqC,KAC3C,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAE1B;;;OAGG;IACH,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,aAAa,KAAK,IAAI,CAAC;CAC3C,CAAC;AAcF;;GAEG;AACH,wBAAgB,qCAAqC,CAAC,aAAa,SAAS,YAAY,EACtF,SAAS,EAAE,8BAA8B,CAAC,aAAa,CAAC,EACxD,YAAY,EAAE,cAAc,GAC3B,aAAa,CAqGf"}
|
package/build/index.d.ts
CHANGED
|
@@ -17,6 +17,7 @@ export * from './PermissionsInterface';
|
|
|
17
17
|
export * from './PermissionsHook';
|
|
18
18
|
export * from './Refs';
|
|
19
19
|
export * from './hooks/useReleasingSharedObject';
|
|
20
|
+
export * from './hooks/useReleasingSharedObjectWithLifecycle';
|
|
20
21
|
export * from './reload';
|
|
21
22
|
export { CodedError } from './errors/CodedError';
|
|
22
23
|
export { UnavailabilityError } from './errors/UnavailabilityError';
|
package/build/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,0BAA0B,CAAC;AAClC,OAAO,0BAA0B,CAAC;AAClC,OAAO,YAAY,CAAC;AAEpB,mBAAmB,0BAA0B,CAAC;AAE9C,OAAO,EAAE,YAAY,EAAE,KAAK,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AACtE,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAC9C,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAC9C,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAExC,OAAO,EAAE,OAAO,IAAI,QAAQ,EAAE,MAAM,YAAY,CAAC;AACjD,OAAO,EAAE,OAAO,IAAI,IAAI,EAAE,MAAM,QAAQ,CAAC;AAEzC,YAAY,EAAE,iBAAiB,EAAE,MAAM,4BAA4B,CAAC;AACpE,OAAO,EAAE,wBAAwB,EAAE,MAAM,4BAA4B,CAAC;AAEtE,cAAc,uBAAuB,CAAC;AACtC,cAAc,qBAAqB,CAAC;AACpC,cAAc,qBAAqB,CAAC;AAEpC,cAAc,wBAAwB,CAAC;AACvC,cAAc,mBAAmB,CAAC;AAElC,cAAc,QAAQ,CAAC;AAEvB,cAAc,kCAAkC,CAAC;AACjD,cAAc,UAAU,CAAC;AAGzB,OAAO,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AACjD,OAAO,EAAE,mBAAmB,EAAE,MAAM,8BAA8B,CAAC;AAGnE,OAAO,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAC1D,OAAO,EAAE,OAAO,IAAI,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAErE,OAAO,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,0BAA0B,CAAC;AAClC,OAAO,0BAA0B,CAAC;AAClC,OAAO,YAAY,CAAC;AAEpB,mBAAmB,0BAA0B,CAAC;AAE9C,OAAO,EAAE,YAAY,EAAE,KAAK,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AACtE,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAC9C,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAC9C,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAExC,OAAO,EAAE,OAAO,IAAI,QAAQ,EAAE,MAAM,YAAY,CAAC;AACjD,OAAO,EAAE,OAAO,IAAI,IAAI,EAAE,MAAM,QAAQ,CAAC;AAEzC,YAAY,EAAE,iBAAiB,EAAE,MAAM,4BAA4B,CAAC;AACpE,OAAO,EAAE,wBAAwB,EAAE,MAAM,4BAA4B,CAAC;AAEtE,cAAc,uBAAuB,CAAC;AACtC,cAAc,qBAAqB,CAAC;AACpC,cAAc,qBAAqB,CAAC;AAEpC,cAAc,wBAAwB,CAAC;AACvC,cAAc,mBAAmB,CAAC;AAElC,cAAc,QAAQ,CAAC;AAEvB,cAAc,kCAAkC,CAAC;AACjD,cAAc,+CAA+C,CAAC;AAC9D,cAAc,UAAU,CAAC;AAGzB,OAAO,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AACjD,OAAO,EAAE,mBAAmB,EAAE,MAAM,8BAA8B,CAAC;AAGnE,OAAO,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAC1D,OAAO,EAAE,OAAO,IAAI,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAErE,OAAO,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC"}
|
|
@@ -70,15 +70,19 @@ public:
|
|
|
70
70
|
bool changedStyle = false;
|
|
71
71
|
|
|
72
72
|
if (!isnan(styleWidth)) {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
730
|
-
//
|
|
731
|
-
//
|
|
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)
|
|
@@ -214,6 +214,14 @@ extension ExpoSwiftUI {
|
|
|
214
214
|
public override func didMoveToWindow() {
|
|
215
215
|
super.didMoveToWindow()
|
|
216
216
|
|
|
217
|
+
#if os(iOS)
|
|
218
|
+
if let window {
|
|
219
|
+
// SwiftUI content can open a menu, and UIKit passes the tap that closes it through to
|
|
220
|
+
// React Native underneath. The gate stops that tap from reaching the view below.
|
|
221
|
+
SystemMenuTouchGate.install(in: window)
|
|
222
|
+
}
|
|
223
|
+
#endif
|
|
224
|
+
|
|
217
225
|
if window != nil, let parentController = reactViewController() {
|
|
218
226
|
#if !os(macOS)
|
|
219
227
|
if parentController as? UINavigationController == nil && parentController as? UITabBarController == nil {
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Copyright 2015-present 650 Industries. All rights reserved.
|
|
2
|
+
|
|
3
|
+
#if os(iOS)
|
|
4
|
+
// Also brings in the subclass header, required to set `state` from the touch callbacks
|
|
5
|
+
// and to call `ignore(_:for:)` on React Native's touch handler.
|
|
6
|
+
import UIKit.UIGestureRecognizerSubclass
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
We create a custom gesture recognizer to stop React Root listening to touches when user taps background while Menu is opened.
|
|
10
|
+
Menu when opened attaches a container view `_UIContextMenuContainerView` to the window.
|
|
11
|
+
On tapping it, React Root's `RCTSurfaceTouchHandler` also listens to the touch and causes Pressables to fire onPress.
|
|
12
|
+
*/
|
|
13
|
+
internal final class SystemMenuTouchGate: UIGestureRecognizer {
|
|
14
|
+
init() {
|
|
15
|
+
super.init(target: nil, action: nil)
|
|
16
|
+
// Both default to true. Never hold a touch back, and never let React Native's handler see a
|
|
17
|
+
// reason to cancel: it calls `_cancelTouches` for any outside recognizer that cancels in view.
|
|
18
|
+
delaysTouchesEnded = false
|
|
19
|
+
cancelsTouchesInView = false
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// MARK: - Detecting an open menu
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
The one view UIKit adds to the window for a presented context menu.
|
|
26
|
+
*/
|
|
27
|
+
static func isContextMenuContainerClassName(_ className: String) -> Bool {
|
|
28
|
+
return className == "_UI".appending("ContextMenuContainerView")
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
Consider Menu to be open when the container is present, still accepts interaction, and is modal.
|
|
33
|
+
UIKit turns `isUserInteractionEnabled` off the moment dismissal commits, so taps made during
|
|
34
|
+
the dismiss animation pass through to the app again. `accessibilityViewIsModal` is how UIKit
|
|
35
|
+
marks the container as blocking the content behind it — a semantic signal beside the class name.
|
|
36
|
+
*/
|
|
37
|
+
static func isOpenContextMenuContainer(
|
|
38
|
+
className: String,
|
|
39
|
+
isUserInteractionEnabled: Bool,
|
|
40
|
+
accessibilityViewIsModal: Bool
|
|
41
|
+
) -> Bool {
|
|
42
|
+
return isUserInteractionEnabled && accessibilityViewIsModal && isContextMenuContainerClassName(className)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
static func isContextMenuContainer(_ view: UIView) -> Bool {
|
|
46
|
+
return isOpenContextMenuContainer(
|
|
47
|
+
className: NSStringFromClass(type(of: view)),
|
|
48
|
+
isUserInteractionEnabled: view.isUserInteractionEnabled,
|
|
49
|
+
accessibilityViewIsModal: view.accessibilityViewIsModal
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
UIKit adds the container as a direct subview of the window, above the app's content, so only the
|
|
55
|
+
window's own subviews are worth checking. Anything deeper belongs to the app itself.
|
|
56
|
+
*/
|
|
57
|
+
static func isShowingContextMenu(
|
|
58
|
+
in window: UIWindow,
|
|
59
|
+
isContainer: @MainActor (UIView) -> Bool = SystemMenuTouchGate.isContextMenuContainer
|
|
60
|
+
) -> Bool {
|
|
61
|
+
for subview in window.subviews where isContainer(subview) {
|
|
62
|
+
return true
|
|
63
|
+
}
|
|
64
|
+
return false
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// MARK: - Cancelling React Native's touches
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
Matched by name so that this file needs no React Native headers.
|
|
71
|
+
*/
|
|
72
|
+
static func isSurfaceTouchHandlerClassName(_ className: String) -> Bool {
|
|
73
|
+
return className == "RCTSurfaceTouchHandler"
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
static func isSurfaceTouchHandler(_ recognizer: UIGestureRecognizer) -> Bool {
|
|
77
|
+
return isSurfaceTouchHandlerClassName(NSStringFromClass(type(of: recognizer)))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
React Native's touch handlers along the touched view's superview chain.
|
|
82
|
+
*/
|
|
83
|
+
static func surfaceTouchHandlers(
|
|
84
|
+
above view: UIView?,
|
|
85
|
+
isSurfaceTouchHandler: @MainActor (UIGestureRecognizer) -> Bool = SystemMenuTouchGate.isSurfaceTouchHandler
|
|
86
|
+
) -> [UIGestureRecognizer] {
|
|
87
|
+
var handlers: [UIGestureRecognizer] = []
|
|
88
|
+
var current = view
|
|
89
|
+
while let view = current {
|
|
90
|
+
for recognizer in view.gestureRecognizers ?? [] where isSurfaceTouchHandler(recognizer) {
|
|
91
|
+
handlers.append(recognizer)
|
|
92
|
+
}
|
|
93
|
+
current = view.superview
|
|
94
|
+
}
|
|
95
|
+
return handlers
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// MARK: - Installing
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
Adds one gate to the window. The window owns it for as long as it lives, and the gate stays
|
|
102
|
+
inert until a menu is actually open, so there is nothing to tear down per host.
|
|
103
|
+
*/
|
|
104
|
+
static func install(in window: UIWindow) {
|
|
105
|
+
let isInstalled = window.gestureRecognizers?.contains { $0 is SystemMenuTouchGate } ?? false
|
|
106
|
+
guard !isInstalled else {
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
window.addGestureRecognizer(SystemMenuTouchGate())
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// MARK: - UIGestureRecognizer
|
|
113
|
+
|
|
114
|
+
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
|
|
115
|
+
super.touchesBegan(touches, with: event)
|
|
116
|
+
|
|
117
|
+
if let window = view as? UIWindow, Self.isShowingContextMenu(in: window) {
|
|
118
|
+
for touch in touches {
|
|
119
|
+
for handler in Self.surfaceTouchHandlers(above: touch.view) {
|
|
120
|
+
handler.ignore(touch, for: event)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
state = .failed
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
#endif
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
191
|
-
|
|
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
|
-
|
|
195
|
-
|
|
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
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Copyright 2026-present 650 Industries. All rights reserved.
|
|
2
|
+
|
|
3
|
+
#if os(iOS) || os(tvOS)
|
|
4
|
+
|
|
5
|
+
import UIKit
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
Geometry lookups that start from the view asking the question, because iOS 27 makes iPhone apps
|
|
9
|
+
freely resizable. Neither `UIScreen.main` nor the first key window in any connected scene describes
|
|
10
|
+
the space a given view has, since either can belong to a differently sized scene.
|
|
11
|
+
*/
|
|
12
|
+
public enum SceneGeometry {
|
|
13
|
+
public static func windowScene(for view: UIView? = nil) -> UIWindowScene? {
|
|
14
|
+
if let scene = view?.window?.windowScene {
|
|
15
|
+
return scene
|
|
16
|
+
}
|
|
17
|
+
return foregroundScene() ?? windowScenes().first
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/// Deliberately strict, for callers deciding whether to attach a window to a scene at all.
|
|
21
|
+
public static func foregroundActiveScene() -> UIWindowScene? {
|
|
22
|
+
return windowScenes().first { $0.activationState == .foregroundActive }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/// Nil when no scene is on screen, so callers don't present UI into a background scene.
|
|
26
|
+
public static func foregroundScene() -> UIWindowScene? {
|
|
27
|
+
let scenes = windowScenes()
|
|
28
|
+
return scenes.first { $0.activationState == .foregroundActive }
|
|
29
|
+
?? scenes.first { $0.activationState == .foregroundInactive }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private static func windowScenes() -> [UIWindowScene] {
|
|
33
|
+
return UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
public static func keyWindow(for view: UIView? = nil) -> UIWindow? {
|
|
37
|
+
guard let scene = windowScene(for: view) else {
|
|
38
|
+
return nil
|
|
39
|
+
}
|
|
40
|
+
return scene.windows.first { $0.isKeyWindow } ?? scene.windows.first
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
public static func bounds(for view: UIView? = nil) -> CGRect {
|
|
44
|
+
return (view?.window ?? keyWindow(for: view))?.bounds ?? .zero
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
public static func safeAreaSize(for view: UIView? = nil) -> CGSize {
|
|
48
|
+
guard let window = view?.window ?? keyWindow(for: view) else {
|
|
49
|
+
return .zero
|
|
50
|
+
}
|
|
51
|
+
let safeArea = window.safeAreaLayoutGuide.layoutFrame.size
|
|
52
|
+
if safeArea.width > 0 && safeArea.height > 0 {
|
|
53
|
+
return safeArea
|
|
54
|
+
}
|
|
55
|
+
return window.bounds.size
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// Never cache the result. Scale can differ per scene.
|
|
59
|
+
public static func displayScale(for view: UIView? = nil) -> CGFloat {
|
|
60
|
+
return resolveDisplayScale(candidates: [
|
|
61
|
+
view?.traitCollection.displayScale,
|
|
62
|
+
windowScene(for: view)?.traitCollection.displayScale,
|
|
63
|
+
UITraitCollection.current.displayScale
|
|
64
|
+
])
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/// Returns the first positive candidate. `UITraitCollection` reports an unspecified scale as 0.
|
|
68
|
+
internal static func resolveDisplayScale(candidates: [CGFloat?]) -> CGFloat {
|
|
69
|
+
for case let candidate? in candidates where candidate > 0 {
|
|
70
|
+
return candidate
|
|
71
|
+
}
|
|
72
|
+
return 1
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
#if os(iOS)
|
|
77
|
+
public extension SceneGeometry {
|
|
78
|
+
/// Reads `effectiveGeometry` because `UIWindowScene.interfaceOrientation` is deprecated as of iOS 26.
|
|
79
|
+
static func interfaceOrientation(for view: UIView? = nil) -> UIInterfaceOrientation {
|
|
80
|
+
return windowScene(for: view)?.effectiveGeometry.interfaceOrientation ?? .unknown
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
#endif
|
|
84
|
+
|
|
85
|
+
#endif
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "expo-modules-core",
|
|
3
|
-
"version": "57.0.
|
|
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.
|
|
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": "
|
|
69
|
+
"gitHead": "499024a441d67293aa60afcbe02051999a54f1bb",
|
|
70
70
|
"scripts": {
|
|
71
71
|
"build": "expo-module build",
|
|
72
72
|
"clean": "expo-module clean",
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import type { DependencyList } from 'react';
|
|
4
|
-
import { useEffect, useMemo, useRef } from 'react';
|
|
5
4
|
|
|
5
|
+
import { useReleasingSharedObjectWithLifecycle } from './useReleasingSharedObjectWithLifecycle';
|
|
6
6
|
import type { SharedObject } from '../ts-declarations/SharedObject';
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -12,55 +12,10 @@ export function useReleasingSharedObject<TSharedObject extends SharedObject>(
|
|
|
12
12
|
factory: () => TSharedObject,
|
|
13
13
|
dependencies: DependencyList
|
|
14
14
|
): TSharedObject {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
objectRef.current = factory();
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const object = useMemo(() => {
|
|
25
|
-
let newObject = objectRef.current;
|
|
26
|
-
const dependenciesAreEqual =
|
|
27
|
-
previousDependencies.current?.length === dependencies.length &&
|
|
28
|
-
dependencies.every((value, index) => value === previousDependencies.current[index]);
|
|
29
|
-
|
|
30
|
-
// If the dependencies have changed, schedule the previous object for release and create a new one,
|
|
31
|
-
// otherwise this has been called because of an unrelated fast refresh, and we don't want to release the object.
|
|
32
|
-
if (!newObject || !dependenciesAreEqual) {
|
|
33
|
-
objectRefToRelease.current = objectRef.current;
|
|
34
|
-
newObject = factory();
|
|
35
|
-
objectRef.current = newObject;
|
|
36
|
-
previousDependencies.current = dependencies;
|
|
37
|
-
}
|
|
38
|
-
return newObject;
|
|
39
|
-
}, dependencies);
|
|
40
|
-
|
|
41
|
-
useEffect(() => {
|
|
42
|
-
// When the object changes, release the previous one - it is important to do this in a useEffect, so that we don't release
|
|
43
|
-
// the object during render.
|
|
44
|
-
if (objectRefToRelease.current) {
|
|
45
|
-
objectRefToRelease.current.release();
|
|
46
|
-
objectRefToRelease.current = null;
|
|
47
|
-
}
|
|
48
|
-
}, [object]);
|
|
49
|
-
|
|
50
|
-
useMemo(() => {
|
|
51
|
-
isFastRefresh.current = true;
|
|
52
|
-
}, []);
|
|
53
|
-
|
|
54
|
-
useEffect(() => {
|
|
55
|
-
isFastRefresh.current = false;
|
|
56
|
-
|
|
57
|
-
return () => {
|
|
58
|
-
// This will be called on every fast refresh and on unmount, but we only want to release the object on unmount.
|
|
59
|
-
if (!isFastRefresh.current && objectRef.current) {
|
|
60
|
-
objectRef.current.release();
|
|
61
|
-
}
|
|
62
|
-
};
|
|
63
|
-
}, []);
|
|
64
|
-
|
|
65
|
-
return object;
|
|
15
|
+
return useReleasingSharedObjectWithLifecycle(
|
|
16
|
+
{
|
|
17
|
+
factory,
|
|
18
|
+
},
|
|
19
|
+
dependencies
|
|
20
|
+
);
|
|
66
21
|
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { DependencyList } from 'react';
|
|
4
|
+
import { useEffect, useMemo, useRef } from 'react';
|
|
5
|
+
|
|
6
|
+
import type { SharedObject } from '../ts-declarations/SharedObject';
|
|
7
|
+
|
|
8
|
+
export type ReleasingSharedObjectLifecycleContext = {
|
|
9
|
+
/**
|
|
10
|
+
* The dependency values from the last committed object lifecycle decision.
|
|
11
|
+
*/
|
|
12
|
+
previousDependencies: DependencyList;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The dependency values from the current render.
|
|
16
|
+
*/
|
|
17
|
+
dependencies: DependencyList;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type ReleasingSharedObjectLifecycle<TSharedObject extends SharedObject> = {
|
|
21
|
+
/**
|
|
22
|
+
* Creates the shared object when the hook initializes or when `shouldRecreate` returns `true`.
|
|
23
|
+
*/
|
|
24
|
+
factory: () => TSharedObject;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Called during render when dependencies change to decide whether to replace the object.
|
|
28
|
+
* Return `false` to keep the current object and handle the dependency change with the `update` function.
|
|
29
|
+
* When omitted or `true`, dependency changes recreate the object, matching `useReleasingSharedObject`.
|
|
30
|
+
*
|
|
31
|
+
* Must be a pure function with no side effects — it is called during the render phase and
|
|
32
|
+
* React may invoke it more than once with the same inputs.
|
|
33
|
+
*/
|
|
34
|
+
shouldRecreate?: (
|
|
35
|
+
object: TSharedObject,
|
|
36
|
+
context: ReleasingSharedObjectLifecycleContext
|
|
37
|
+
) => boolean;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Called after commit when dependencies changed and `shouldRecreate` returned `false`.
|
|
41
|
+
* Has no effect unless `shouldRecreate` is provided and returns `false` for the changed
|
|
42
|
+
* dependencies.
|
|
43
|
+
*
|
|
44
|
+
* If the returned `Promise` rejects, the error is logged with `console.error`. Handle errors
|
|
45
|
+
* inside `update` if specific error handling is needed.
|
|
46
|
+
*
|
|
47
|
+
* If a subsequent dependency change or unmount requires the object to be released while an
|
|
48
|
+
* async update is still in-flight, the release is deferred until the update settles.
|
|
49
|
+
*/
|
|
50
|
+
update?: (
|
|
51
|
+
object: TSharedObject,
|
|
52
|
+
context: ReleasingSharedObjectLifecycleContext
|
|
53
|
+
) => void | Promise<void>;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Releases an object after it has been replaced or when the component unmounts.
|
|
57
|
+
* When omitted, the object's `release` method is called.
|
|
58
|
+
*/
|
|
59
|
+
release?: (object: TSharedObject) => void;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
type PendingUpdate<TSharedObject extends SharedObject> = {
|
|
63
|
+
object: TSharedObject;
|
|
64
|
+
context: ReleasingSharedObjectLifecycleContext;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
function dependenciesAreEqual(previousDependencies: DependencyList, dependencies: DependencyList) {
|
|
68
|
+
return (
|
|
69
|
+
previousDependencies.length === dependencies.length &&
|
|
70
|
+
dependencies.every((value, index) => value === previousDependencies[index])
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Returns a shared object, delegating dependency changes to lifecycle callbacks.
|
|
76
|
+
*/
|
|
77
|
+
export function useReleasingSharedObjectWithLifecycle<TSharedObject extends SharedObject>(
|
|
78
|
+
lifecycle: ReleasingSharedObjectLifecycle<TSharedObject>,
|
|
79
|
+
dependencies: DependencyList
|
|
80
|
+
): TSharedObject {
|
|
81
|
+
const objectRef = useRef<TSharedObject | null>(null);
|
|
82
|
+
const objectRefToRelease = useRef<TSharedObject | null>(null);
|
|
83
|
+
const pendingUpdateRef = useRef<PendingUpdate<TSharedObject> | null>(null);
|
|
84
|
+
const pendingUpdatePromiseRef = useRef<Promise<void> | null>(null);
|
|
85
|
+
const isFastRefresh = useRef(false);
|
|
86
|
+
const previousDependencies = useRef<DependencyList>(dependencies);
|
|
87
|
+
const lifecycleRef = useRef(lifecycle);
|
|
88
|
+
|
|
89
|
+
// Keep lifecycle callbacks fresh without making effects depend on the lifecycle object identity.
|
|
90
|
+
lifecycleRef.current = lifecycle;
|
|
91
|
+
|
|
92
|
+
if (objectRef.current == null) {
|
|
93
|
+
objectRef.current = lifecycleRef.current.factory();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const object = useMemo(() => {
|
|
97
|
+
let newObject = objectRef.current;
|
|
98
|
+
const context = {
|
|
99
|
+
previousDependencies: previousDependencies.current,
|
|
100
|
+
dependencies,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// If the dependencies have changed, let the caller decide whether the object should be
|
|
104
|
+
// replaced or updated in place. Otherwise this has been called because of an unrelated
|
|
105
|
+
// fast refresh, and we don't want to release the object.
|
|
106
|
+
if (!newObject || !dependenciesAreEqual(previousDependencies.current, dependencies)) {
|
|
107
|
+
if (!newObject || (lifecycleRef.current.shouldRecreate?.(newObject, context) ?? true)) {
|
|
108
|
+
objectRefToRelease.current = objectRef.current;
|
|
109
|
+
newObject = lifecycleRef.current.factory();
|
|
110
|
+
objectRef.current = newObject;
|
|
111
|
+
} else if (lifecycleRef.current.update) {
|
|
112
|
+
pendingUpdateRef.current = {
|
|
113
|
+
object: newObject,
|
|
114
|
+
context,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
previousDependencies.current = dependencies;
|
|
118
|
+
}
|
|
119
|
+
return newObject;
|
|
120
|
+
}, dependencies);
|
|
121
|
+
|
|
122
|
+
function releaseObject(obj: TSharedObject) {
|
|
123
|
+
(lifecycleRef.current.release ?? ((o: TSharedObject) => o.release()))(obj);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
// When the object changes, release the previous one - it is important to do this in a useEffect, so that we don't release
|
|
128
|
+
// the object during render. If an async update is still in-flight, defer the release until it settles.
|
|
129
|
+
if (objectRefToRelease.current) {
|
|
130
|
+
const toRelease = objectRefToRelease.current;
|
|
131
|
+
objectRefToRelease.current = null;
|
|
132
|
+
const doRelease = () => releaseObject(toRelease);
|
|
133
|
+
if (pendingUpdatePromiseRef.current) {
|
|
134
|
+
pendingUpdatePromiseRef.current.then(doRelease, doRelease);
|
|
135
|
+
} else {
|
|
136
|
+
doRelease();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (pendingUpdateRef.current) {
|
|
141
|
+
const pendingUpdate = pendingUpdateRef.current;
|
|
142
|
+
pendingUpdateRef.current = null;
|
|
143
|
+
const result = lifecycleRef.current.update?.(pendingUpdate.object, pendingUpdate.context);
|
|
144
|
+
if (result instanceof Promise) {
|
|
145
|
+
pendingUpdatePromiseRef.current = result;
|
|
146
|
+
result.then(
|
|
147
|
+
() => {
|
|
148
|
+
pendingUpdatePromiseRef.current = null;
|
|
149
|
+
},
|
|
150
|
+
(error) => {
|
|
151
|
+
pendingUpdatePromiseRef.current = null;
|
|
152
|
+
console.error(error);
|
|
153
|
+
}
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}, dependencies);
|
|
158
|
+
|
|
159
|
+
useMemo(() => {
|
|
160
|
+
isFastRefresh.current = true;
|
|
161
|
+
}, []);
|
|
162
|
+
|
|
163
|
+
useEffect(() => {
|
|
164
|
+
isFastRefresh.current = false;
|
|
165
|
+
|
|
166
|
+
return () => {
|
|
167
|
+
// This will be called on every fast refresh and on unmount, but we only want to release the object on unmount.
|
|
168
|
+
if (!isFastRefresh.current && objectRef.current) {
|
|
169
|
+
const obj = objectRef.current;
|
|
170
|
+
const doRelease = () => releaseObject(obj);
|
|
171
|
+
if (pendingUpdatePromiseRef.current) {
|
|
172
|
+
pendingUpdatePromiseRef.current.then(doRelease, doRelease);
|
|
173
|
+
} else {
|
|
174
|
+
doRelease();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
}, []);
|
|
179
|
+
|
|
180
|
+
return object;
|
|
181
|
+
}
|