expo-updates 55.0.20 → 55.0.21

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,11 +10,19 @@
10
10
 
11
11
  ### 💡 Others
12
12
 
13
+ ## 55.0.21 — 2026-04-21
14
+
15
+ ### 💡 Others
16
+
17
+ - [ios] resolve Expo.plist lookup in brownfield xcframework builds ([#44645](https://github.com/expo/expo/pull/44645) by [@gabrieldonadel](https://github.com/gabrieldonadel))
18
+ - [ios] Support multiple root view creations in brownfield ([#44771](https://github.com/expo/expo/pull/44771) by [@gabrieldonadel](https://github.com/gabrieldonadel))
19
+
13
20
  ## 55.0.20 — 2026-04-09
14
21
 
15
22
  ### 🐛 Bug fixes
16
23
 
17
24
  - Pass absolute path to CLI helpers when creating build manifest, since the underlying functions now handle entry file inputs properly, instead of applying `mainModuleName` semantics to them ([#44414](https://github.com/expo/expo/pull/44414) by [@kitten](https://github.com/kitten))
25
+ - [ios] Fix loading assets in brownfield ([#44724](https://github.com/expo/expo/pull/44724) by [@gabrieldonadel](https://github.com/gabrieldonadel))
18
26
 
19
27
  ## 55.0.19 — 2026-04-07
20
28
 
@@ -42,7 +42,7 @@ expoModule {
42
42
  }
43
43
 
44
44
  group = 'host.exp.exponent'
45
- version = '55.0.20'
45
+ version = '55.0.21'
46
46
 
47
47
  // Utility method to derive boolean values from the environment or from Java properties,
48
48
  // and return them as strings to be used in BuildConfig fields
@@ -89,7 +89,7 @@ android {
89
89
  namespace "expo.modules.updates"
90
90
  defaultConfig {
91
91
  versionCode 31
92
- versionName '55.0.20'
92
+ versionName '55.0.21'
93
93
  consumerProguardFiles("proguard-rules.pro")
94
94
  testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
95
95
 
@@ -149,6 +149,7 @@ public protocol InternalAppControllerInterface: AppControllerInterface {
149
149
  var reloadScreenManager: Reloadable? { get }
150
150
 
151
151
  var eventManager: UpdatesEventManager { get }
152
+ var isStarted: Bool { get }
152
153
  func onEventListenerStartObserving()
153
154
 
154
155
  func getConstantsForModule() -> UpdatesModuleConstants
@@ -21,7 +21,7 @@ public final class AppLauncherNoDatabase: NSObject, AppLauncher {
21
21
 
22
22
  public func launchUpdate() {
23
23
  precondition(assetFilesMap == nil, "assetFilesMap should be null for embedded updates")
24
- launchAssetUrl = Bundle.main.url(
24
+ launchAssetUrl = updatesBundle.url(
25
25
  forResource: EmbeddedAppLoader.EXUpdatesBareEmbeddedBundleFilename,
26
26
  withExtension: EmbeddedAppLoader.EXUpdatesBareEmbeddedBundleFileType
27
27
  )
@@ -179,7 +179,7 @@ public class AppLauncherWithDatabase: NSObject, AppLauncher {
179
179
 
180
180
  if launchedUpdate.status == UpdateStatus.StatusEmbedded {
181
181
  precondition(assetFilesMap == nil, "assetFilesMap should be null for embedded updates")
182
- launchAssetUrl = Bundle.main.url(
182
+ launchAssetUrl = updatesBundle.url(
183
183
  forResource: EmbeddedAppLoader.EXUpdatesBareEmbeddedBundleFilename,
184
184
  withExtension: EmbeddedAppLoader.EXUpdatesBareEmbeddedBundleFileType
185
185
  )
@@ -55,6 +55,7 @@ public final class DevLauncherAppController: NSObject, InternalAppControllerInte
55
55
  public var embeddedUpdateId: UUID?
56
56
 
57
57
  public var isEnabled: Bool
58
+ public let isStarted = false
58
59
 
59
60
  public let eventManager: UpdatesEventManager = NoOpUpdatesEventManager()
60
61
  public var reloadScreenManager: Reloadable? = ReloadScreenManager()
@@ -29,7 +29,7 @@ public class DisabledAppController: InternalAppControllerInterface, UpdatesInter
29
29
  public var reloadScreenManager: Reloadable?
30
30
 
31
31
  public let isActiveController = false
32
- private var isStarted: Bool = false
32
+ public private(set) var isStarted: Bool = false
33
33
  private var startupStartTime: DispatchTime?
34
34
  private var startupEndTime: DispatchTime?
35
35
 
@@ -39,7 +39,7 @@ public class EnabledAppController: InternalAppControllerInterface, UpdatesInterf
39
39
  private let updatesDirectoryInternal: URL
40
40
  private let controllerQueue = DispatchQueue(label: "expo.controller.ControllerQueue")
41
41
  public let isActiveController = true
42
- private var isStarted = false
42
+ public private(set) var isStarted = false
43
43
  private var startupStartTime: DispatchTime?
44
44
  private var startupEndTime: DispatchTime?
45
45
 
@@ -32,10 +32,24 @@ public final class ExpoUpdatesReactDelegateHandler: ExpoReactDelegateHandler, Ap
32
32
  return nil
33
33
  }
34
34
 
35
+ // If startup already completed, create the real view directly to handle
36
+ // brownfield re-mounts and simultaneous multi-view scenarios, given that
37
+ // didStartWithSuccess fires only once per controller lifetime.
38
+ if controller.isStarted, let launchAssetUrl = controller.launchAssetUrl() {
39
+ return reactDelegate.reactNativeFactory.recreateRootView(
40
+ withBundleURL: launchAssetUrl,
41
+ moduleName: moduleName,
42
+ initialProps: initialProperties,
43
+ launchOptions: launchOptions
44
+ )
45
+ }
46
+
35
47
  self.reactDelegate = reactDelegate
36
48
  self.launchOptions = launchOptions
37
- controller.delegate = self
38
- controller.start()
49
+ if !controller.isStarted {
50
+ controller.delegate = self
51
+ controller.start()
52
+ }
39
53
 
40
54
  self.rootViewModuleName = moduleName
41
55
  self.rootViewInitialProperties = initialProperties
@@ -83,14 +97,27 @@ public final class ExpoUpdatesReactDelegateHandler: ExpoReactDelegateHandler, Ap
83
97
  launchOptions: self.launchOptions
84
98
  )
85
99
 
86
- let window = getWindow()
87
- let rootViewController = reactDelegate.createRootViewController()
88
100
  #if os(iOS) || os(tvOS)
89
101
  rootView.backgroundColor = self.deferredRootView?.backgroundColor ?? UIColor.white
90
- rootViewController.view = rootView
91
- window.rootViewController = rootViewController
92
- window.makeKeyAndVisible()
102
+
103
+ // In brownfield setups, the deferred root view is embedded within the host app's
104
+ // view hierarchy (e.g. inside a NavigationController). Replacing the window's root
105
+ // view controller would break the host app's navigation. Instead, find the view
106
+ // controller that owns the deferred view and replace its view in-place.
107
+ if let deferredRootView = self.deferredRootView,
108
+ let owningViewController = findViewController(for: deferredRootView),
109
+ owningViewController != getWindow().rootViewController {
110
+ owningViewController.view = rootView
111
+ } else {
112
+ let window = getWindow()
113
+ let rootViewController = reactDelegate.createRootViewController()
114
+ rootViewController.view = rootView
115
+ window.rootViewController = rootViewController
116
+ window.makeKeyAndVisible()
117
+ }
93
118
  #else
119
+ let window = getWindow()
120
+ let rootViewController = reactDelegate.createRootViewController()
94
121
  rootViewController.view = rootView
95
122
  rootView.frame = window.frame
96
123
  window.contentViewController = rootViewController
@@ -133,6 +160,22 @@ public final class ExpoUpdatesReactDelegateHandler: ExpoReactDelegateHandler, Ap
133
160
 
134
161
  return view
135
162
  }
163
+
164
+ /**
165
+ Finds the nearest view controller that owns the given view by walking
166
+ up the responder chain. Returns the first UIViewController whose view
167
+ matches the target view.
168
+ */
169
+ private func findViewController(for view: UIView) -> UIViewController? {
170
+ var responder: UIResponder? = view.next
171
+ while let current = responder {
172
+ if let viewController = current as? UIViewController, viewController.view == view {
173
+ return viewController
174
+ }
175
+ responder = current.next
176
+ }
177
+ return nil
178
+ }
136
179
  #endif
137
180
 
138
181
  private func getWindow() -> UIWindow {
@@ -145,7 +145,7 @@ public final class UpdatesConfig: NSObject {
145
145
  }
146
146
 
147
147
  private static func configDictionaryWithExpoPlist(mergingOtherDictionary: [String: Any]?) throws -> [String: Any] {
148
- guard let configPlistPath = Bundle.main.path(forResource: PlistName, ofType: "plist") else {
148
+ guard let configPlistPath = updatesBundle.path(forResource: PlistName, ofType: "plist") else {
149
149
  throw UpdatesConfigError.ExpoUpdatesConfigPlistError
150
150
  }
151
151
 
@@ -16,6 +16,22 @@ internal extension Array where Element: Equatable {
16
16
  }
17
17
  }
18
18
 
19
+ /**
20
+ * In brownfield setups the Expo project is packaged as an xcframework, so
21
+ * resources like Expo.plist, main.jsbundle, and image assets live in the
22
+ * framework bundle instead of the host app's main bundle.
23
+ *
24
+ * `updatesBundle` resolves to whichever bundle actually contains the
25
+ * expo-updates resources at runtime: `Bundle.main` for standard apps,
26
+ * or the framework bundle for brownfield xcframeworks.
27
+ */
28
+ internal let updatesBundle: Bundle = {
29
+ if Bundle.main.path(forResource: "Expo", ofType: "plist") != nil {
30
+ return Bundle.main
31
+ }
32
+ return Bundle(for: UpdatesUtils.self)
33
+ }()
34
+
19
35
  @objc(EXUpdatesUtils)
20
36
  @objcMembers
21
37
  public final class UpdatesUtils: NSObject {
@@ -110,16 +126,16 @@ public final class UpdatesUtils: NSObject {
110
126
 
111
127
  internal static func url(forBundledAsset asset: UpdateAsset) -> URL? {
112
128
  guard let mainBundleDir = asset.mainBundleDir else {
113
- return Bundle.main.url(forResource: asset.mainBundleFilename, withExtension: asset.type)
129
+ return updatesBundle.url(forResource: asset.mainBundleFilename, withExtension: asset.type)
114
130
  }
115
- return Bundle.main.url(forResource: asset.mainBundleFilename, withExtension: asset.type, subdirectory: mainBundleDir)
131
+ return updatesBundle.url(forResource: asset.mainBundleFilename, withExtension: asset.type, subdirectory: mainBundleDir)
116
132
  }
117
133
 
118
134
  internal static func path(forBundledAsset asset: UpdateAsset) -> String? {
119
135
  guard let mainBundleDir = asset.mainBundleDir else {
120
- return Bundle.main.path(forResource: asset.mainBundleFilename, ofType: asset.type)
136
+ return updatesBundle.path(forResource: asset.mainBundleFilename, ofType: asset.type)
121
137
  }
122
- return Bundle.main.path(forResource: asset.mainBundleFilename, ofType: asset.type, inDirectory: mainBundleDir)
138
+ return updatesBundle.path(forResource: asset.mainBundleFilename, ofType: asset.type, inDirectory: mainBundleDir)
123
139
  }
124
140
 
125
141
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-updates",
3
- "version": "55.0.20",
3
+ "version": "55.0.21",
4
4
  "description": "Fetches and manages remotely-hosted assets and updates to your app's JS bundle.",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
@@ -45,9 +45,9 @@
45
45
  "chalk": "^4.1.2",
46
46
  "debug": "^4.3.4",
47
47
  "expo-eas-client": "~55.0.5",
48
- "expo-manifests": "~55.0.15",
48
+ "expo-manifests": "~55.0.16",
49
49
  "expo-structured-headers": "~55.0.2",
50
- "expo-updates-interface": "~55.1.5",
50
+ "expo-updates-interface": "~55.1.6",
51
51
  "getenv": "^2.0.0",
52
52
  "glob": "^13.0.0",
53
53
  "ignore": "^5.3.1",
@@ -71,5 +71,5 @@
71
71
  "react": "*",
72
72
  "react-native": "*"
73
73
  },
74
- "gitHead": "b0ada30fd6a819c5f98a23d99b1b89a2ed68743d"
74
+ "gitHead": "e37e614d97c3ca53f16b91609a787675d044c284"
75
75
  }