expo-updates 58.0.7 → 58.0.9

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 (24) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/android/build.gradle +8 -2
  3. package/android/src/main/java/expo/modules/updates/IUpdatesController.kt +2 -1
  4. package/android/src/main/java/expo/modules/updates/UpdatesModule.kt +11 -2
  5. package/android/src/main/java/expo/modules/updates/loader/FileDownloader.kt +28 -1
  6. package/android/src/main/java/expo/modules/updates/procedures/FetchUpdateProcedure.kt +2 -1
  7. package/android/src/main/java/expo/modules/updates/procedures/StartupProcedure.kt +4 -3
  8. package/android/src/main/java/expo/modules/updates/statemachine/UpdatesStateMachine.kt +31 -2
  9. package/expo-updates-gradle-plugin/.gradle/9.3.1/executionHistory/executionHistory.bin +0 -0
  10. package/expo-updates-gradle-plugin/.gradle/9.3.1/executionHistory/executionHistory.lock +0 -0
  11. package/expo-updates-gradle-plugin/.gradle/9.4.1/executionHistory/executionHistory.bin +0 -0
  12. package/expo-updates-gradle-plugin/.gradle/9.4.1/executionHistory/executionHistory.lock +0 -0
  13. package/expo-updates-gradle-plugin/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
  14. package/expo-updates-gradle-plugin/.gradle/buildOutputCleanup/cache.properties +1 -1
  15. package/expo-updates-gradle-plugin/.gradle/file-system.probe +0 -0
  16. package/expo-updates-gradle-plugin/build/kotlin/compileKotlin/cacheable/last-build.bin +0 -0
  17. package/ios/EXUpdates/AppLoader/FileDownloader.swift +15 -1
  18. package/ios/EXUpdates/Exceptions.swift +53 -0
  19. package/ios/EXUpdates/Procedures/StartupProcedure.swift +13 -2
  20. package/ios/EXUpdates/UpdatesModule.swift +4 -4
  21. package/ios/EXUpdates/UpdatesStateMachine.swift +44 -4
  22. package/ios/EXUpdates.podspec +11 -0
  23. package/package.json +3 -3
  24. package/scripts/with-node.sh +28 -10
package/CHANGELOG.md CHANGED
@@ -10,6 +10,18 @@
10
10
 
11
11
  ### 💡 Others
12
12
 
13
+ ## 58.0.9 — 2026-09-25
14
+
15
+ _This version does not introduce any user-facing changes._
16
+
17
+ ## 58.0.8 — 2026-09-23
18
+
19
+ ### 🐛 Bug fixes
20
+
21
+ - Log a warning naming the event and its error message when the updates state machine drops an event that is not allowed from the current state. Previously such an event was discarded silently in release builds, so an error could disappear without a trace. ([#50163](https://github.com/expo/expo/pull/50163) by [@douglowder](https://github.com/douglowder))
22
+ - Add an `EX_UPDATES_ASSERT_INVALID_STATE` build flag that makes the updates state machine stop the app when it drops an event, so an invalid transition fails an E2E run instead of passing unnoticed. ([#50163](https://github.com/expo/expo/pull/50163) by [@douglowder](https://github.com/douglowder))
23
+ - [iOS] Fix `with-node.sh` ignoring the app's `NODE_BINARY` and `.xcode.env` when building without CocoaPods. ([#50084](https://github.com/expo/expo/pull/50084) by [@chrfalch](https://github.com/chrfalch))
24
+
13
25
  ## 58.0.7 — 2026-09-22
14
26
 
15
27
  _This version does not introduce any user-facing changes._
@@ -44,6 +56,8 @@ _This version does not introduce any user-facing changes._
44
56
 
45
57
  - [iOS] Apply bundle diffs against the embedded bundle in the app binary when the launched update is the embedded one, instead of failing to resolve a patch base and downloading the full bundle. ([#50018](https://github.com/expo/expo/pull/50018) by [@alanjhughes](https://github.com/alanjhughes))
46
58
  - [Android] Apply bundle diffs against the embedded bundle in the app binary when the launched update is the embedded one, instead of failing to resolve a patch base and downloading the full bundle. ([#50019](https://github.com/expo/expo/pull/50019) by [@alanjhughes](https://github.com/alanjhughes))
59
+ - Fix issues with error propagation when `checkForUpdateAsync`, `fetchUpdateAsync`, `readLogEntriesAsync` and `clearLogEntriesAsync` reject with an error, so that error causes/reasons are preserved. ([#50098](https://github.com/expo/expo/pull/50098) by [@douglowder](https://github.com/douglowder))
60
+ - [iOS] Report a startup update failure through `downloadError` when the updates state machine is idle, instead of dropping it, so that `useUpdates()` reflects background update failures as it already did on Android. ([#50098](https://github.com/expo/expo/pull/50098) by [@douglowder](https://github.com/douglowder))
47
61
 
48
62
  ## 58.0.0 — 2026-09-10
49
63
 
@@ -42,7 +42,7 @@ expoModule {
42
42
  }
43
43
 
44
44
  group = 'host.exp.exponent'
45
- version = '58.0.7'
45
+ version = '58.0.9'
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
@@ -78,6 +78,11 @@ def exUpdatesAndroidDelayLoadApp = getBoolStringFromPropOrEnv("EX_UPDATES_ANDROI
78
78
  // If true, updates will copy embedded assets to file system when startup. (default false)
79
79
  def exUpdatesCopyEmbeddedAssets = getBoolStringFromPropOrEnv("EX_UPDATES_COPY_EMBEDDED_ASSETS", false)
80
80
 
81
+ // If true, the state machine throws when it drops an event that is not allowed from the current
82
+ // state, instead of only logging a warning. E2E tests turn this on so an invalid transition fails
83
+ // the test run rather than passing unnoticed. (default false)
84
+ def exUpdatesAssertInvalidState = getBoolStringFromPropOrEnv("EX_UPDATES_ASSERT_INVALID_STATE", false)
85
+
81
86
  def useDevClient = findProject(":expo-dev-client") != null
82
87
 
83
88
  def reactNativeArchitectures() {
@@ -98,7 +103,7 @@ android {
98
103
  namespace "expo.modules.updates"
99
104
  defaultConfig {
100
105
  versionCode 31
101
- versionName '58.0.7'
106
+ versionName '58.0.9'
102
107
  consumerProguardFiles("proguard-rules.pro")
103
108
  testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
104
109
 
@@ -106,6 +111,7 @@ android {
106
111
  buildConfigField("boolean", "EX_UPDATES_CUSTOM_INIT", exUpdatesCustomInit)
107
112
  buildConfigField("boolean", "EX_UPDATES_ANDROID_DELAY_LOAD_APP", exUpdatesAndroidDelayLoadApp)
108
113
  buildConfigField("boolean", "EX_UPDATES_COPY_EMBEDDED_ASSETS", exUpdatesCopyEmbeddedAssets)
114
+ buildConfigField("boolean", "EX_UPDATES_ASSERT_INVALID_STATE", exUpdatesAssertInvalidState)
109
115
  buildConfigField("boolean", "USE_DEV_CLIENT", useDevClient.toString())
110
116
 
111
117
  externalNativeBuild {
@@ -4,6 +4,7 @@ import android.os.Bundle
4
4
  import com.facebook.react.ReactHost
5
5
  import com.facebook.react.bridge.ReactContext
6
6
  import com.facebook.react.devsupport.interfaces.DevSupportManager
7
+ import expo.modules.core.logging.localizedMessageWithCauseLocalizedMessage
7
8
  import expo.modules.updates.db.entity.AssetEntity
8
9
  import expo.modules.updates.db.entity.UpdateEntity
9
10
  import expo.modules.updates.events.IUpdatesEventManager
@@ -104,7 +105,7 @@ interface IUpdatesController {
104
105
  ) {
105
106
  fun toModuleConstantsMap(): Map<String, Any?> = mutableMapOf<String, Any?>().apply {
106
107
  this["isEmergencyLaunch"] = emergencyLaunchException != null
107
- this["emergencyLaunchReason"] = emergencyLaunchException?.message
108
+ this["emergencyLaunchReason"] = emergencyLaunchException?.localizedMessageWithCauseLocalizedMessage()
108
109
  this["isEmbeddedLaunch"] = embeddedUpdate !== null && launchedUpdate?.id?.equals(embeddedUpdate.id) ?: false
109
110
  this["isEnabled"] = isEnabled
110
111
  this["launchDuration"] = launchDuration?.toLong(DurationUnit.MILLISECONDS)
@@ -3,6 +3,7 @@ package expo.modules.updates
3
3
  import android.content.Context
4
4
  import android.net.Uri
5
5
  import android.os.Bundle
6
+ import expo.modules.core.logging.localizedMessageWithCauseLocalizedMessage
6
7
  import expo.modules.kotlin.exception.CodedException
7
8
  import expo.modules.kotlin.exception.Exceptions
8
9
  import expo.modules.kotlin.functions.Coroutine
@@ -69,7 +70,11 @@ class UpdatesModule : Module(), IUpdatesEventManagerObserver {
69
70
  AsyncFunction("checkForUpdateAsync") Coroutine { ->
70
71
  when (val result = UpdatesController.instance.checkForUpdate()) {
71
72
  is IUpdatesController.CheckForUpdateResult.ErrorResult -> {
72
- throw CodedException("ERR_UPDATES_CHECK", "Failed to check for update", result.error)
73
+ throw CodedException(
74
+ "ERR_UPDATES_CHECK",
75
+ "Failed to check for update: ${result.error.localizedMessageWithCauseLocalizedMessage()}",
76
+ result.error
77
+ )
73
78
  }
74
79
 
75
80
  is IUpdatesController.CheckForUpdateResult.NoUpdateAvailable -> {
@@ -103,7 +108,11 @@ class UpdatesModule : Module(), IUpdatesEventManagerObserver {
103
108
  AsyncFunction("fetchUpdateAsync") Coroutine { ->
104
109
  when (val result = UpdatesController.instance.fetchUpdate()) {
105
110
  is IUpdatesController.FetchUpdateResult.ErrorResult -> {
106
- throw CodedException("ERR_UPDATES_FETCH", "Failed to download new update", result.error)
111
+ throw CodedException(
112
+ "ERR_UPDATES_FETCH",
113
+ "Failed to download new update: ${result.error.localizedMessageWithCauseLocalizedMessage()}",
114
+ result.error
115
+ )
107
116
  }
108
117
 
109
118
  is IUpdatesController.FetchUpdateResult.Failure -> {
@@ -2,6 +2,7 @@ package expo.modules.updates.loader
2
2
 
3
3
  import androidx.annotation.VisibleForTesting
4
4
  import com.facebook.react.modules.network.OkHttpClientProvider
5
+ import expo.modules.core.logging.localizedMessageWithCauseLocalizedMessage
5
6
  import expo.modules.jsonutils.require
6
7
  import expo.modules.structuredheaders.Dictionary
7
8
  import expo.modules.structuredheaders.OuterList
@@ -699,6 +700,30 @@ class FileDownloader(
699
700
  )
700
701
  }
701
702
 
703
+ /**
704
+ * Summarizes an unsuccessful response for use as an error message. The status code is included
705
+ * because the body alone is often an opaque HTML page, and the body is truncated because it ends
706
+ * up in the error that `checkForUpdateAsync` and `fetchUpdateAsync` reject with.
707
+ */
708
+ private fun describeUnsuccessfulResponse(response: Response): String {
709
+ val body = try {
710
+ response.body?.string()
711
+ } catch (e: IOException) {
712
+ logger.warn("Could not read body of unsuccessful remote update response: ${e.localizedMessageWithCauseLocalizedMessage()}", UpdatesErrorCode.UpdateFailedToLoad)
713
+ null
714
+ }
715
+ val summary = body?.takeIf { it.isNotBlank() }?.let { truncateForErrorMessage(it) } ?: "Unknown error"
716
+ return "HTTP ${response.code}: $summary"
717
+ }
718
+
719
+ private fun truncateForErrorMessage(body: String): String {
720
+ return if (body.length <= MAX_ERROR_BODY_LENGTH) {
721
+ body
722
+ } else {
723
+ "${body.take(MAX_ERROR_BODY_LENGTH)}… (truncated, ${body.length} characters total)"
724
+ }
725
+ }
726
+
702
727
  suspend fun downloadRemoteUpdate(
703
728
  extraHeaders: JSONObject?
704
729
  ): UpdateResponse {
@@ -709,7 +734,7 @@ class FileDownloader(
709
734
 
710
735
  if (!response.isSuccessful) {
711
736
  val message = "Remote update request not successful"
712
- val underlyingError = IOException(response.body?.string() ?: "Unknown error")
737
+ val underlyingError = IOException(describeUnsuccessfulResponse(response))
713
738
  logger.error(message, underlyingError, UpdatesErrorCode.UpdateFailedToLoad)
714
739
  throw IOException(message, underlyingError)
715
740
  }
@@ -983,6 +1008,8 @@ class FileDownloader(
983
1008
  }
984
1009
 
985
1010
  companion object {
1011
+ private const val MAX_ERROR_BODY_LENGTH = 512
1012
+
986
1013
  fun getExtraHeadersForRemoteUpdateRequest(
987
1014
  database: UpdatesDatabase,
988
1015
  configuration: UpdatesConfiguration,
@@ -1,6 +1,7 @@
1
1
  package expo.modules.updates.procedures
2
2
 
3
3
  import android.content.Context
4
+ import expo.modules.core.logging.localizedMessageWithCauseLocalizedMessage
4
5
  import expo.modules.updates.IUpdatesController
5
6
  import expo.modules.updates.UpdatesConfiguration
6
7
  import expo.modules.updates.db.UpdatesDatabase
@@ -41,7 +42,7 @@ class FetchUpdateProcedure(
41
42
  } catch (e: Exception) {
42
43
  logger.error("Failed to download new update", e)
43
44
  procedureContext.processStateEvent(
44
- UpdatesStateEvent.DownloadError("Failed to download new update: ${e.message}")
45
+ UpdatesStateEvent.DownloadError("Failed to download new update: ${e.localizedMessageWithCauseLocalizedMessage()}")
45
46
  )
46
47
  callback(IUpdatesController.FetchUpdateResult.ErrorResult(e))
47
48
  } finally {
@@ -2,6 +2,7 @@ package expo.modules.updates.procedures
2
2
 
3
3
  import android.content.Context
4
4
  import com.facebook.react.devsupport.interfaces.DevSupportManager
5
+ import expo.modules.core.logging.localizedMessageWithCauseLocalizedMessage
5
6
  import expo.modules.updates.UpdatesConfiguration
6
7
  import expo.modules.updates.db.BuildData
7
8
  import expo.modules.updates.db.UpdatesDatabase
@@ -164,18 +165,18 @@ class StartupProcedure(
164
165
  UpdatesStateValue.Idle -> {
165
166
  procedureContext.processStateEvent(UpdatesStateEvent.Download())
166
167
  procedureContext.processStateEvent(
167
- UpdatesStateEvent.DownloadError(exception.message ?: "")
168
+ UpdatesStateEvent.DownloadError(exception.localizedMessageWithCauseLocalizedMessage())
168
169
  )
169
170
  }
170
171
  UpdatesStateValue.Checking -> {
171
172
  procedureContext.processStateEvent(
172
- UpdatesStateEvent.CheckError(exception.message ?: "")
173
+ UpdatesStateEvent.CheckError(exception.localizedMessageWithCauseLocalizedMessage())
173
174
  )
174
175
  }
175
176
  else -> {
176
177
  // .downloading
177
178
  procedureContext.processStateEvent(
178
- UpdatesStateEvent.DownloadError(exception.message ?: "")
179
+ UpdatesStateEvent.DownloadError(exception.localizedMessageWithCauseLocalizedMessage())
179
180
  )
180
181
  }
181
182
  }
@@ -1,6 +1,7 @@
1
1
  package expo.modules.updates.statemachine
2
2
 
3
3
  import expo.modules.manifests.core.toMap
4
+ import expo.modules.updates.BuildConfig
4
5
  import expo.modules.updates.EnabledUpdatesController
5
6
  import expo.modules.updates.events.IUpdatesEventManager
6
7
  import expo.modules.updates.logging.UpdatesLogger
@@ -114,18 +115,46 @@ class UpdatesStateMachine(
114
115
  private fun transition(event: UpdatesStateEvent): Boolean {
115
116
  val allowedEvents: Set<UpdatesStateEventType> = updatesStateAllowedEvents[state] ?: setOf()
116
117
  if (!allowedEvents.contains(event.type)) {
117
- assert(false) { "UpdatesState: invalid transition requested: state = $state, event = ${event.type}" }
118
+ reportDroppedEvent(event)
118
119
  return false
119
120
  }
120
121
  val newStateValue = updatesStateTransitions[event.type] ?: UpdatesStateValue.Idle
121
122
  if (!validUpdatesStateValues.contains(newStateValue)) {
122
- assert(false) { "UpdatesState: invalid transition requested: state = $state, event = ${event.type}" }
123
+ reportDroppedEvent(event)
123
124
  return false
124
125
  }
125
126
  state = newStateValue
126
127
  return true
127
128
  }
128
129
 
130
+ /**
131
+ Records an event the machine cannot process. The drop is always logged, so it is visible through
132
+ `readLogEntriesAsync` in a shipping app. With EX_UPDATES_ASSERT_INVALID_STATE the drop also
133
+ throws, so an invalid transition fails an E2E run instead of passing unnoticed.
134
+ */
135
+ private fun reportDroppedEvent(event: UpdatesStateEvent) {
136
+ val message = droppedEventWarning(event)
137
+ logger.warn(message)
138
+ if (BuildConfig.EX_UPDATES_ASSERT_INVALID_STATE) {
139
+ throw AssertionError(message)
140
+ }
141
+ }
142
+
143
+ /**
144
+ The warning written when an event is dropped. It carries the error text of an error event,
145
+ because some callers do not log the failure themselves before sending the event, which would
146
+ leave the updates log with no record of what was lost.
147
+ */
148
+ private fun droppedEventWarning(event: UpdatesStateEvent): String {
149
+ val errorMessage = when (event) {
150
+ is UpdatesStateEvent.CheckError -> event.error.message
151
+ is UpdatesStateEvent.DownloadError -> event.error.message
152
+ else -> null
153
+ }
154
+ val error = errorMessage?.let { ", error = $it" } ?: ""
155
+ return "UpdatesState: invalid transition requested, event dropped: state = $state, event = ${event.type}$error"
156
+ }
157
+
129
158
  fun sendContextToJS() {
130
159
  eventManager.sendStateMachineContextEvent(context)
131
160
  }
@@ -1,2 +1,2 @@
1
- #Mon Sep 21 11:42:05 IST 2026
1
+ #Fri Sep 25 09:12:16 IST 2026
2
2
  gradle.version=9.4.1
@@ -40,8 +40,22 @@ private extension String {
40
40
 
41
41
  return self
42
42
  }
43
+
44
+ /**
45
+ Truncates a response body for inclusion in an error message. The message reaches JavaScript and
46
+ crash reporters, so an unbounded body (typically an HTML error page) must not be embedded whole.
47
+ Mirrors `MAX_ERROR_BODY_LENGTH` in the Android `FileDownloader`.
48
+ */
49
+ func truncatedForErrorMessage() -> String {
50
+ if count <= maxErrorBodyLength {
51
+ return self
52
+ }
53
+ return "\(prefix(maxErrorBodyLength))… (truncated, \(count) characters total)"
54
+ }
43
55
  }
44
56
 
57
+ private let maxErrorBodyLength = 512
58
+
45
59
  private extension Dictionary where Iterator.Element == (key: String, value: Any) {
46
60
  func stringValueForCaseInsensitiveKey(_ searchKey: Key) -> String? {
47
61
  let valueRaw = self.first { (key: Key, _: Value) in
@@ -1219,7 +1233,7 @@ public final class FileDownloader {
1219
1233
  httpResponse.statusCode < 200 || httpResponse.statusCode >= 300 {
1220
1234
  let encoding = FileDownloader.encoding(fromResponse: httpResponse)
1221
1235
  let body = data.let { it in
1222
- String(data: it, encoding: encoding)
1236
+ String(data: it, encoding: encoding)?.truncatedForErrorMessage()
1223
1237
  } ?? "Unknown body response"
1224
1238
  let cause = UpdatesError.fileDownloaderHTTPResponseError(statusCode: httpResponse.statusCode, body: body)
1225
1239
  self.logger.error(cause: cause, code: .unknown)
@@ -99,4 +99,57 @@ internal final class InvalidRequestHeadersOverrideException: Exception {
99
99
  }
100
100
  }
101
101
 
102
+ /**
103
+ * Base class for exceptions that wrap an underlying error and need its description to reach
104
+ * JavaScript. Subclasses override `reason` to include the underlying error's description.
105
+ */
106
+ internal class UpdatesUnderlyingErrorException: Exception, @unchecked Sendable {
107
+ internal let underlyingError: Error
108
+
109
+ internal init(_ underlyingError: Error, file: String = #fileID, line: UInt = #line, function: String = #function) {
110
+ self.underlyingError = underlyingError
111
+ super.init(file: file, line: line, function: function)
112
+ }
113
+ }
114
+
115
+ internal final class CheckForUpdateException: UpdatesUnderlyingErrorException, @unchecked Sendable {
116
+ override var code: String {
117
+ "ERR_UPDATES_CHECK"
118
+ }
119
+
120
+ override var reason: String {
121
+ "Failed to check for update: \(underlyingError.localizedDescription)"
122
+ }
123
+ }
124
+
125
+ internal final class FetchUpdateException: UpdatesUnderlyingErrorException, @unchecked Sendable {
126
+ override var code: String {
127
+ "ERR_UPDATES_FETCH"
128
+ }
129
+
130
+ override var reason: String {
131
+ "Failed to download new update: \(underlyingError.localizedDescription)"
132
+ }
133
+ }
134
+
135
+ internal final class ReadLogEntriesException: UpdatesUnderlyingErrorException, @unchecked Sendable {
136
+ override var code: String {
137
+ "ERR_UPDATES_READ_LOGS"
138
+ }
139
+
140
+ override var reason: String {
141
+ "Failed to read log entries: \(underlyingError.localizedDescription)"
142
+ }
143
+ }
144
+
145
+ internal final class ClearLogEntriesException: UpdatesUnderlyingErrorException, @unchecked Sendable {
146
+ override var code: String {
147
+ "ERR_UPDATES_READ_LOGS"
148
+ }
149
+
150
+ override var reason: String {
151
+ "Failed to clear log entries: \(underlyingError.localizedDescription)"
152
+ }
153
+ }
154
+
102
155
  // swiftlint:enable line_length
@@ -211,10 +211,21 @@ final class StartupProcedure: StateMachineProcedure, AppLoaderTaskDelegate, AppL
211
211
  )
212
212
  // Since errors can happen through a number of paths, we do these checks
213
213
  // to make sure the state machine is valid
214
- if self.procedureContext.getCurrentState() == .checking {
214
+ switch self.procedureContext.getCurrentState() {
215
+ case .checking:
215
216
  self.procedureContext.processStateEvent(.checkError(errorMessage: error.localizedDescription))
216
- } else if self.procedureContext.getCurrentState() == .downloading {
217
+ case .downloading:
217
218
  self.procedureContext.processStateEvent(.downloadError(errorMessage: error.localizedDescription))
219
+ case .idle:
220
+ // `downloadError` on its own is an illegal transition out of idle and would be dropped,
221
+ // leaving `useUpdates()` with no record of the failure. Move into the downloading state
222
+ // first, as the Android implementation does.
223
+ self.procedureContext.processStateEvent(.download)
224
+ self.procedureContext.processStateEvent(.downloadError(errorMessage: error.localizedDescription))
225
+ case .restarting:
226
+ // The machine accepts no events while restarting, so there is nowhere to report this. The
227
+ // error is already recorded in the log above.
228
+ logger.warn(message: "Background update failed while restarting: \(error.localizedDescription)")
218
229
  }
219
230
  case .updateAvailable:
220
231
  remoteLoadStatus = .NewUpdateLoaded
@@ -68,7 +68,7 @@ public final class UpdatesModule: Module, UpdatesEventManagerObserver {
68
68
  ])
69
69
  return
70
70
  case .error(let error):
71
- promise.reject("ERR_UPDATES_CHECK", error.localizedDescription)
71
+ promise.reject(CheckForUpdateException(error))
72
72
  return
73
73
  }
74
74
  } error: { error in
@@ -97,7 +97,7 @@ public final class UpdatesModule: Module, UpdatesEventManagerObserver {
97
97
  do {
98
98
  return try UpdatesLogReader().getLogEntries(newerThan: Date(timeIntervalSinceNow: TimeInterval(-1 * (maxAge / 1000))))
99
99
  } catch {
100
- throw Exception(name: "ERR_UPDATES_READ_LOGS", description: error.localizedDescription)
100
+ throw ReadLogEntriesException(error)
101
101
  }
102
102
  }
103
103
 
@@ -107,7 +107,7 @@ public final class UpdatesModule: Module, UpdatesEventManagerObserver {
107
107
  promise.resolve(nil)
108
108
  return
109
109
  }
110
- promise.reject("ERR_UPDATES_READ_LOGS", error.localizedDescription)
110
+ promise.reject(ClearLogEntriesException(error))
111
111
  }
112
112
  }
113
113
 
@@ -134,7 +134,7 @@ public final class UpdatesModule: Module, UpdatesEventManagerObserver {
134
134
  ])
135
135
  return
136
136
  case .error(let error):
137
- promise.reject("ERR_UPDATES_FETCH", error.localizedDescription)
137
+ promise.reject(FetchUpdateException(error))
138
138
  return
139
139
  }
140
140
  } error: { error in
@@ -89,6 +89,23 @@ internal enum UpdatesStateEvent {
89
89
  }
90
90
  }
91
91
 
92
+ /**
93
+ The failure text carried by an error event. The state machine logs it when it drops an event,
94
+ so the message does not disappear with the event.
95
+ */
96
+ var errorMessage: String? {
97
+ switch self {
98
+ case let .checkError(errorMessage):
99
+ return errorMessage
100
+ case let .downloadError(errorMessage):
101
+ return errorMessage
102
+ case .startStartup, .endStartup, .check, .checkCompleteUnavailable, .checkCompleteWithUpdate,
103
+ .checkCompleteWithRollback, .download, .downloadCompleteUnavailable, .downloadCompleteWithUpdate,
104
+ .downloadCompleteWithRollback, .downloadProgress, .restart:
105
+ return nil
106
+ }
107
+ }
108
+
92
109
  var toMap: [String: Any] {
93
110
  switch self {
94
111
  case .checkCompleteWithUpdate(manifest: let manifest):
@@ -418,12 +435,12 @@ internal class UpdatesStateMachine {
418
435
  private func transition(_ event: UpdatesStateEvent) -> Bool {
419
436
  let allowedEvents: Set<UpdatesStateEvent.InternalType> = UpdatesStateMachine.updatesStateAllowedEvents[state] ?? []
420
437
  if !allowedEvents.contains(event.type) {
421
- assertionFailure("UpdatesState: invalid transition requested: state = \(state), event = \(event.type)")
438
+ reportDroppedEvent(event)
422
439
  return false
423
440
  }
424
441
  let newStateValue = UpdatesStateMachine.updatesStateTransitions[event.type] ?? .idle
425
442
  if !validUpdatesStateValues.contains(newStateValue) {
426
- assertionFailure("UpdatesState: invalid transition requested: state = \(state), event = \(event.type)")
443
+ reportDroppedEvent(event)
427
444
  return false
428
445
  }
429
446
  // Successful transition
@@ -431,6 +448,29 @@ internal class UpdatesStateMachine {
431
448
  return true
432
449
  }
433
450
 
451
+ /**
452
+ Records an event the machine cannot process. The drop is always logged, so it is visible through
453
+ `readLogEntriesAsync` in a shipping app. With EX_UPDATES_ASSERT_INVALID_STATE the drop also
454
+ traps, so an invalid transition fails an E2E run instead of passing unnoticed.
455
+ */
456
+ private func reportDroppedEvent(_ event: UpdatesStateEvent) {
457
+ let message = droppedEventWarning(event)
458
+ logger.warn(message: message)
459
+ #if EX_UPDATES_ASSERT_INVALID_STATE
460
+ preconditionFailure(message)
461
+ #endif
462
+ }
463
+
464
+ /**
465
+ The warning written when an event is dropped. It carries the error text of an error event,
466
+ because some callers do not log the failure themselves before sending the event, which would
467
+ leave the updates log with no record of what was lost.
468
+ */
469
+ private func droppedEventWarning(_ event: UpdatesStateEvent) -> String {
470
+ let error = event.errorMessage.map { ", error = \($0)" } ?? ""
471
+ return "UpdatesState: invalid transition requested, event dropped: state = \(state), event = \(event.type)\(error)"
472
+ }
473
+
434
474
  /**
435
475
  Given an allowed event and a context, return a new context with the changes
436
476
  made by processing the event.
@@ -547,8 +587,8 @@ internal class UpdatesStateMachine {
547
587
 
548
588
  /**
549
589
  For a particular machine state, only certain events may be processed.
550
- If the machine receives an unexpected event, an assertion failure will occur
551
- and the app will crash.
590
+ If the machine receives an unexpected event, the event is dropped and a warning
591
+ is written to the updates log.
552
592
  */
553
593
  private static let updatesStateAllowedEvents: [UpdatesStateValue: Set<UpdatesStateEvent.InternalType>] = [
554
594
  .idle: [.startStartup, .endStartup, .check, .download, .restart],
@@ -78,6 +78,11 @@ Pod::Spec.new do |s|
78
78
  ex_updates_native_debug = ENV['EX_UPDATES_NATIVE_DEBUG'] == '1'
79
79
  ex_updates_custom_init = ENV['EX_UPDATES_CUSTOM_INIT'] == '1'
80
80
  ex_updates_copy_embedded_assets = ENV['EX_UPDATES_COPY_EMBEDDED_ASSETS'] == '1'
81
+ # If set, the state machine traps when it drops an event that is not allowed from the current
82
+ # state, instead of only logging a warning. E2E tests turn this on so an invalid transition fails
83
+ # the test run rather than passing unnoticed. Both '1' and 'true' are accepted, so the same value
84
+ # works here and in the Android Gradle build.
85
+ ex_updates_assert_invalid_state = ['1', 'true'].include?(ENV['EX_UPDATES_ASSERT_INVALID_STATE'].to_s.downcase)
81
86
  if ex_updates_native_debug
82
87
  other_debug_c_flags << ' -DEX_UPDATES_NATIVE_DEBUG=1'
83
88
  other_debug_swift_flags << ' -DEX_UPDATES_NATIVE_DEBUG'
@@ -94,6 +99,12 @@ Pod::Spec.new do |s|
94
99
  other_release_c_flags << ' -DEX_UPDATES_COPY_EMBEDDED_ASSETS=1'
95
100
  other_release_swift_flags << ' -DEX_UPDATES_COPY_EMBEDDED_ASSETS'
96
101
  end
102
+ if ex_updates_assert_invalid_state
103
+ other_debug_c_flags << ' -DEX_UPDATES_ASSERT_INVALID_STATE=1'
104
+ other_debug_swift_flags << ' -DEX_UPDATES_ASSERT_INVALID_STATE'
105
+ other_release_c_flags << ' -DEX_UPDATES_ASSERT_INVALID_STATE=1'
106
+ other_release_swift_flags << ' -DEX_UPDATES_ASSERT_INVALID_STATE'
107
+ end
97
108
  if use_dev_client
98
109
  other_debug_c_flags << ' -DUSE_DEV_CLIENT=1'
99
110
  other_debug_swift_flags << ' -DUSE_DEV_CLIENT'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-updates",
3
- "version": "58.0.7",
3
+ "version": "58.0.9",
4
4
  "description": "Fetches and manages remotely-hosted assets and updates to your app's JS bundle.",
5
5
  "keywords": [
6
6
  "expo",
@@ -71,7 +71,7 @@
71
71
  "@types/picomatch": "^4.0.0",
72
72
  "@types/react": "~19.3.0",
73
73
  "@vercel/ncc": "^0.38.3",
74
- "expo-dev-client": "58.0.5",
74
+ "expo-dev-client": "58.0.7",
75
75
  "expo-module-scripts": "56.0.3",
76
76
  "express": "^5.1.0",
77
77
  "form-data": "^4.0.4",
@@ -91,7 +91,7 @@
91
91
  "optional": true
92
92
  }
93
93
  },
94
- "gitHead": "add8cbf49f477cfd2283ae05af35b4713a773c55",
94
+ "gitHead": "ad41d067bd3df6dd12f1788acc6b54a68e7a650d",
95
95
  "scripts": {
96
96
  "build": "pnpm run build:lib && pnpm run build:plugin && pnpm run build:utils && pnpm run build:cli",
97
97
  "build:lib": "expo-build src",
@@ -9,20 +9,38 @@
9
9
 
10
10
  CURR_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
11
11
 
12
- # Start with a default
13
- NODE_BINARY=$(command -v node)
12
+ # CocoaPods sets PODS_ROOT, and its env files override any inherited NODE_BINARY.
13
+ # Without CocoaPods (SwiftPM) the calling build phase may already have resolved
14
+ # NODE_BINARY, so keep it and read the app's env files from SRCROOT only if unset.
15
+ # The mode is fixed here because the env files may change PODS_ROOT.
16
+ if [[ -n "$PODS_ROOT" ]]; then
17
+ USES_COCOAPODS=1
18
+ # Start with a default
19
+ NODE_BINARY=$(command -v node)
20
+ ENV_PATH="$PODS_ROOT/../.xcode.env"
21
+ else
22
+ USES_COCOAPODS=
23
+ ENV_PATH="$SRCROOT/.xcode.env"
24
+ fi
14
25
  export NODE_BINARY
15
26
 
16
- # Override the default with the global environment
17
- ENV_PATH="$PODS_ROOT/../.xcode.env"
18
- if [[ -f "$ENV_PATH" ]]; then
19
- source "$ENV_PATH"
27
+ if [[ -n "$USES_COCOAPODS" || -z "$NODE_BINARY" ]]; then
28
+ # Override the default with the global environment
29
+ if [[ -f "$ENV_PATH" ]]; then
30
+ source "$ENV_PATH"
31
+ fi
32
+
33
+ # Override the global with the local environment
34
+ LOCAL_ENV_PATH="${ENV_PATH}.local"
35
+ if [[ -f "$LOCAL_ENV_PATH" ]]; then
36
+ source "$LOCAL_ENV_PATH"
37
+ fi
20
38
  fi
21
39
 
22
- # Override the global with the local environment
23
- LOCAL_ENV_PATH="${ENV_PATH}.local"
24
- if [[ -f "$LOCAL_ENV_PATH" ]]; then
25
- source "$LOCAL_ENV_PATH"
40
+ if [[ -z "$USES_COCOAPODS" ]]; then
41
+ NODE_BINARY="${NODE_BINARY:-$(command -v node)}"
42
+ # An env file that unsets NODE_BINARY also drops its export.
43
+ export NODE_BINARY
26
44
  fi
27
45
 
28
46
  if [[ -n "$NODE_BINARY" && -x "$NODE_BINARY" ]]; then