expo-updates 55.0.31 → 55.0.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,18 @@
10
10
 
11
11
  ### 💡 Others
12
12
 
13
+ ## 55.0.33 — 2026-09-29
14
+
15
+ ### 🐛 Bug fixes
16
+
17
+ - Fix the embedded manifest giving every duplicate asset scale the same `packagerHash`, which made updates download assets already in the binary. ([#50757](https://github.com/expo/expo/pull/50757) by [@alanjhughes](https://github.com/alanjhughes))
18
+
19
+ ## 55.0.32 — 2026-09-28
20
+
21
+ ### 🐛 Bug fixes
22
+
23
+ - [iOS] Reuse an embedded asset from the app binary instead of re-downloading it when an update requires the same asset key. ([#49650](https://github.com/expo/expo/pull/49650) by [@alanjhughes](https://github.com/alanjhughes))
24
+
13
25
  ## 55.0.31 — 2026-09-17
14
26
 
15
27
  ### 🐛 Bug fixes
@@ -42,7 +42,7 @@ expoModule {
42
42
  }
43
43
 
44
44
  group = 'host.exp.exponent'
45
- version = '55.0.31'
45
+ version = '55.0.33'
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.31'
92
+ versionName '55.0.33'
93
93
  consumerProguardFiles("proguard-rules.pro")
94
94
  testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
95
95
 
@@ -49,6 +49,16 @@ open class AppLoader: NSObject {
49
49
  private let arrayLock: NSLock = NSLock()
50
50
  private let completionQueue: DispatchQueue
51
51
 
52
+ /// Overridable for testing, where the bundle has no `app.manifest`.
53
+ internal lazy var embeddedUpdate: Update? = EmbeddedAppLoader.embeddedManifest(withConfig: config, database: database)
54
+
55
+ /// Overridable for testing, where fixtures live in the test bundle rather than in `updatesBundle`.
56
+ internal var embeddedAssetsBundle: Bundle = updatesBundle
57
+
58
+ /// Whether an asset with no database row may be copied out of the app binary rather than
59
+ /// downloaded. Only remote loads have anything to reuse.
60
+ internal var reusesEmbeddedAssets: Bool { true }
61
+
52
62
  public init(
53
63
  config: UpdatesConfig,
54
64
  logger: UpdatesLogger,
@@ -208,7 +218,13 @@ open class AppLoader: NSObject {
208
218
  if let assets = updateManifest.assets(),
209
219
  !assets.isEmpty {
210
220
  self.assetsToLoad = assets
211
- let embeddedUpdate = EmbeddedAppLoader.embeddedManifest(withConfig: self.config, database: self.database)
221
+ let embeddedUpdate = self.embeddedUpdate
222
+ let embeddedAssetsByKey = Dictionary(
223
+ (embeddedUpdate?.assets() ?? []).compactMap { embeddedAsset in
224
+ embeddedAsset.key.map { ($0, embeddedAsset) }
225
+ },
226
+ uniquingKeysWith: { first, _ in first }
227
+ )
212
228
  let requestedUpdate: Update? = {
213
229
  if let launchedUpdate = self.launchedUpdate,
214
230
  launchedUpdate.updateId == updateManifest.updateId {
@@ -251,7 +267,12 @@ open class AppLoader: NSObject {
251
267
  }
252
268
  }
253
269
  } else {
254
- self.downloadAsset(asset, extraHeaders: extraHeaders)
270
+ FileDownloader.assetFilesQueue.async {
271
+ if self.copyAssetFromEmbeddedBundleIfPresent(asset, embeddedAssetsByKey: embeddedAssetsByKey) {
272
+ return
273
+ }
274
+ self.downloadAsset(asset, extraHeaders: extraHeaders)
275
+ }
255
276
  }
256
277
  }
257
278
  } else {
@@ -260,6 +281,60 @@ open class AppLoader: NSObject {
260
281
  }
261
282
  }
262
283
 
284
+ /**
285
+ * Satisfies an asset from the embedded update in the app binary rather than downloading it.
286
+ * Embedded assets have no database row when copying is off, so the lookup above misses them and
287
+ * a remote update would otherwise re-download bytes that already ship in the app.
288
+ *
289
+ * Returns true when it handled the asset. Every failure returns false so the caller downloads it.
290
+ *
291
+ * Must be called on `FileDownloader.assetFilesQueue`.
292
+ */
293
+ internal func copyAssetFromEmbeddedBundleIfPresent(
294
+ _ asset: UpdateAsset,
295
+ embeddedAssetsByKey: [String: UpdateAsset]
296
+ ) -> Bool {
297
+ guard reusesEmbeddedAssets else {
298
+ return false
299
+ }
300
+
301
+ guard let key = asset.key,
302
+ UpdatesUtils.isSafeFilename(asset.filename),
303
+ let embeddedAsset = embeddedAssetsByKey[key],
304
+ embeddedAsset.mainBundleFilename != nil,
305
+ let bundlePath = UpdatesUtils.path(forBundledAsset: embeddedAsset, in: embeddedAssetsBundle) else {
306
+ return false
307
+ }
308
+
309
+ guard let data = try? Data(contentsOf: URL(fileURLWithPath: bundlePath), options: .mappedIfSafe) else {
310
+ logger.warn(message: "AppLoader: could not read embedded asset \(key), downloading it instead")
311
+ return false
312
+ }
313
+
314
+ // Keys are content-addressed, so a mismatch means the manifest is wrong about what we shipped.
315
+ if let expectedHash = asset.expectedHash {
316
+ let actualHash = UpdatesUtils.base64UrlEncodedSHA256WithData(data)
317
+ if expectedHash != actualHash {
318
+ logger.warn(
319
+ message: "AppLoader: embedded asset \(key) hashed to \(actualHash) but the update expects \(expectedHash), downloading it instead"
320
+ )
321
+ return false
322
+ }
323
+ }
324
+
325
+ do {
326
+ try data.write(to: directory.appendingPathComponent(asset.filename), options: .atomic)
327
+ } catch {
328
+ logger.warn(message: "AppLoader: could not copy embedded asset \(key) into the updates directory, downloading it instead")
329
+ return false
330
+ }
331
+
332
+ DispatchQueue.global().async {
333
+ self.handleAssetDownload(withData: data, response: nil, asset: asset)
334
+ }
335
+ return true
336
+ }
337
+
263
338
  public func handleAssetDownloadAlreadyExists(_ asset: UpdateAsset) {
264
339
  arrayLock.lock()
265
340
  assetsToLoad.remove(asset)
@@ -205,6 +205,10 @@ public final class EmbeddedAppLoader: AppLoader {
205
205
  }
206
206
  }
207
207
 
208
+ /// This loader is already reading the bundle it would copy from, so the copy-enabled path keeps
209
+ /// resolving files through the launcher as it did before.
210
+ override internal var reusesEmbeddedAssets: Bool { false }
211
+
208
212
  override public func downloadAsset(_ asset: UpdateAsset, extraHeaders: [String: Any]) {
209
213
  FileDownloader.assetFilesQueue.async {
210
214
  self.handleAssetDownloadAlreadyExists(asset)
@@ -149,10 +149,14 @@ public final class UpdatesUtils: NSObject {
149
149
  }
150
150
 
151
151
  internal static func path(forBundledAsset asset: UpdateAsset) -> String? {
152
+ return path(forBundledAsset: asset, in: updatesBundle)
153
+ }
154
+
155
+ internal static func path(forBundledAsset asset: UpdateAsset, in bundle: Bundle) -> String? {
152
156
  guard let mainBundleDir = asset.mainBundleDir else {
153
- return updatesBundle.path(forResource: asset.mainBundleFilename, ofType: asset.type)
157
+ return bundle.path(forResource: asset.mainBundleFilename, ofType: asset.type)
154
158
  }
155
- return updatesBundle.path(forResource: asset.mainBundleFilename, ofType: asset.type, inDirectory: mainBundleDir)
159
+ return bundle.path(forResource: asset.mainBundleFilename, ofType: asset.type, inDirectory: mainBundleDir)
156
160
  }
157
161
 
158
162
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-updates",
3
- "version": "55.0.31",
3
+ "version": "55.0.33",
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",
@@ -71,5 +71,5 @@
71
71
  "react": "*",
72
72
  "react-native": "*"
73
73
  },
74
- "gitHead": "167ec74a43d77e1b61f751440334f9e645f8d2be"
74
+ "gitHead": "77679dfebfabc9ddca6cc2eecf80715ed4a8d58e"
75
75
  }
@@ -45,14 +45,16 @@ async function createManifestForBuildAsync(platform, projectRoot, destinationDir
45
45
  if (!asset.fileHashes) {
46
46
  throw new Error('The hashAssetFiles Metro plugin is not configured. You need to add a metro.config.js to your project that configures Metro to use this plugin. See https://github.com/expo/expo/blob/main/packages/expo-updates/README.md#metroconfigjs for an example.');
47
47
  }
48
- (0, filterPlatformAssetScales_1.filterPlatformAssetScales)(platform, asset.scales).forEach(function (scale) {
48
+ const platformScales = new Set((0, filterPlatformAssetScales_1.filterPlatformAssetScales)(platform, asset.scales));
49
+ asset.scales.forEach(function (scale, index) {
50
+ if (!platformScales.has(scale)) {
51
+ return;
52
+ }
49
53
  const baseAssetInfoForManifest = {
50
54
  name: asset.name,
51
55
  type: asset.type,
52
56
  scale,
53
- // `fileHashes` is parallel to the unfiltered `asset.scales`, so it must be indexed by the
54
- // scale's position there rather than by its position in the filtered list.
55
- packagerHash: asset.fileHashes[asset.scales.indexOf(scale)],
57
+ packagerHash: asset.fileHashes[index],
56
58
  subdirectory: asset.httpServerLocation,
57
59
  };
58
60
  if (platform === 'ios') {
@@ -64,14 +64,16 @@ export async function createManifestForBuildAsync(
64
64
  'The hashAssetFiles Metro plugin is not configured. You need to add a metro.config.js to your project that configures Metro to use this plugin. See https://github.com/expo/expo/blob/main/packages/expo-updates/README.md#metroconfigjs for an example.'
65
65
  );
66
66
  }
67
- filterPlatformAssetScales(platform, asset.scales).forEach(function (scale) {
67
+ const platformScales = new Set(filterPlatformAssetScales(platform, asset.scales));
68
+ asset.scales.forEach(function (scale, index) {
69
+ if (!platformScales.has(scale)) {
70
+ return;
71
+ }
68
72
  const baseAssetInfoForManifest = {
69
73
  name: asset.name,
70
74
  type: asset.type,
71
75
  scale,
72
- // `fileHashes` is parallel to the unfiltered `asset.scales`, so it must be indexed by the
73
- // scale's position there rather than by its position in the filtered list.
74
- packagerHash: asset.fileHashes[asset.scales.indexOf(scale)],
76
+ packagerHash: asset.fileHashes[index],
75
77
  subdirectory: asset.httpServerLocation,
76
78
  };
77
79
  if (platform === 'ios') {