@bravemobile/react-native-code-push 13.1.0-beta.6 → 13.2.0

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/README.md CHANGED
@@ -394,7 +394,18 @@ module.exports = Config;
394
394
  - Modifying an existing release history with the `update-history` command.
395
395
 
396
396
 
397
- **(2) For `code-push.config.ts` (TypeScript) to work properly, you may need to update your `tsconfig.json`.**
397
+ **(2) A `code-push.config.ts` (TypeScript) file needs a loader installed.**
398
+
399
+ Install `tsx`, which requires no further setup:
400
+
401
+ ```bash
402
+ npm install --save-dev tsx
403
+ ```
404
+
405
+ `ts-node` also keeps working, and is used when `tsx` is not installed. Note that `ts-node` is no
406
+ longer maintained, so support for it is **deprecated and will be removed in a future major
407
+ version** — please migrate to `tsx`. Until then, `ts-node` needs the following `tsconfig.json`
408
+ setup:
398
409
 
399
410
  ```diff
400
411
  {
@@ -414,6 +425,8 @@ module.exports = Config;
414
425
 
415
426
  ```
416
427
 
428
+ A `code-push.config.js` (JavaScript) file needs neither.
429
+
417
430
 
418
431
  ### 6. Diff Updates (Optional)
419
432
 
@@ -21,10 +21,6 @@
21
21
  private final ** mBundleLoader;
22
22
  }
23
23
  # bridgeless
24
- -keepclassmembers class com.facebook.react.defaults.DefaultReactHostDelegate {
25
- private ** jsBundleLoader;
26
- }
27
- # bridgeless
28
24
  -keepclassmembers class * extends com.facebook.react.runtime.ReactHostDelegate {
29
25
  private ** jsBundleLoader;
30
26
  private ** _jsBundleLoader;
@@ -35,9 +31,6 @@
35
31
  private final ** reactHostDelegate; # RN 0.81+
36
32
  }
37
33
 
38
- # Can't find referenced class org.bouncycastle.**
39
- -dontwarn com.nimbusds.jose.**
40
-
41
34
  # The binary patch applier is found by name from the native library.
42
35
  -keepclasseswithmembernames class com.microsoft.codepush.react.HDiffPatchNative {
43
36
  native <methods>;
@@ -51,6 +51,9 @@ public class CodePush extends TurboReactPackage {
51
51
  return mServerUrl;
52
52
  }
53
53
 
54
+ /**
55
+ * Entry point used by React Native autolinking to create and retain the package instance.
56
+ */
54
57
  public static synchronized CodePush getInstance(Context context, boolean isDebugMode) {
55
58
  if (mCurrentInstance == null) {
56
59
  mCurrentInstance = new CodePush(context, isDebugMode);
@@ -59,6 +59,15 @@ public class CodePushNativeModule extends NativeCodePushSpec {
59
59
  private boolean _restartInProgress = false;
60
60
  private ArrayList<Boolean> _restartQueue = new ArrayList<>();
61
61
 
62
+ /**
63
+ * Whether the React Native instance this module was created for is still the one running.
64
+ *
65
+ * A reload replaces the instance while work this module started can still be in flight,
66
+ * and a frame callback posted to the process-wide choreographer outlives the executor
67
+ * this module shuts down. Read from whichever thread that work is on, so it is volatile.
68
+ */
69
+ private volatile boolean mGenerationAlive = true;
70
+
62
71
  public CodePushNativeModule(ReactApplicationContext reactContext, CodePush codePush, CodePushUpdateManager codePushUpdateManager, CodePushTelemetryManager codePushTelemetryManager, SettingsManager settingsManager) {
63
72
  super(reactContext);
64
73
 
@@ -85,6 +94,7 @@ public class CodePushNativeModule extends NativeCodePushSpec {
85
94
 
86
95
  @Override
87
96
  public void invalidate() {
97
+ mGenerationAlive = false;
88
98
  clearLifecycleEventListener();
89
99
  mBackgroundExecutor.shutdownNow();
90
100
  super.invalidate();
@@ -264,6 +274,14 @@ public class CodePushNativeModule extends NativeCodePushSpec {
264
274
  }
265
275
 
266
276
  private void emitDownloadProgressEvent(DownloadProgress downloadProgress) {
277
+ // Every progress event arrives here, including the ones a frame callback delivers
278
+ // after this module was invalidated: shutting the executor down does not cancel a
279
+ // callback already posted to the choreographer, and the context it would emit
280
+ // through has been destroyed by then.
281
+ if (!mGenerationAlive) {
282
+ return;
283
+ }
284
+
267
285
  if (mEventEmitterCallback != null) {
268
286
  emitOnDownloadProgress(downloadProgress.createWritableMap());
269
287
  return;
@@ -275,6 +293,13 @@ public class CodePushNativeModule extends NativeCodePushSpec {
275
293
  }
276
294
 
277
295
  private void restartAppInternal(boolean onlyIfUpdateIsPending) {
296
+ // A restart requested by an instance that has been replaced would reload the one
297
+ // that replaced it, which nobody asked for.
298
+ if (!mGenerationAlive) {
299
+ CodePushUtils.log("Ignoring a restart requested by a React Native instance that is no longer running");
300
+ return;
301
+ }
302
+
278
303
  if (this._restartInProgress) {
279
304
  CodePushUtils.log("Restart request queued until the current restart is completed");
280
305
  this._restartQueue.add(onlyIfUpdateIsPending);
@@ -1,20 +1,15 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
3
  import { createRequire } from "module";
4
- const nodeRequire = createRequire(import.meta.url);
5
4
  /**
6
5
  * allows to require a config file with .ts extension
7
6
  */
8
7
  function requireConfig(filePath) {
9
8
  const ext = path.extname(filePath);
9
+ // Resolve the loader from the project the config file lives in, not from the CLI install.
10
+ const projectRequire = createRequire(filePath);
10
11
  if (ext === '.ts') {
11
- try {
12
- nodeRequire('ts-node/register');
13
- }
14
- catch {
15
- console.error('ts-node not found. Please install ts-node as a devDependency.');
16
- process.exit(1);
17
- }
12
+ return unwrapDefaultExport(requireTsConfig(projectRequire, filePath));
18
13
  }
19
14
  else if (ext === '.js') {
20
15
  // do nothing
@@ -22,7 +17,38 @@ function requireConfig(filePath) {
22
17
  else {
23
18
  throw new Error(`Unsupported file extension: ${ext}`);
24
19
  }
25
- return nodeRequire(filePath);
20
+ return unwrapDefaultExport(projectRequire(filePath));
21
+ }
22
+ /**
23
+ * tsx is preferred: it needs no tsconfig setup and resolves tsconfig `paths` aliases on its own.
24
+ * ts-node keeps working for projects that already have it configured.
25
+ */
26
+ function requireTsConfig(projectRequire, filePath) {
27
+ if (canResolve(projectRequire, 'tsx/cjs/api')) {
28
+ const { require: tsxRequire } = projectRequire('tsx/cjs/api');
29
+ return tsxRequire(filePath, filePath);
30
+ }
31
+ if (canResolve(projectRequire, 'ts-node/register')) {
32
+ console.warn('warn: Loading the config file with ts-node, which is no longer maintained. Support for it ' +
33
+ 'will be removed in a future major version - please install tsx instead (`npm i -D tsx`).');
34
+ projectRequire('ts-node/register');
35
+ return projectRequire(filePath);
36
+ }
37
+ console.error('A TypeScript config file needs a loader. Please install tsx as a devDependency (`npm i -D tsx`).');
38
+ process.exit(1);
39
+ }
40
+ function canResolve(projectRequire, id) {
41
+ try {
42
+ projectRequire.resolve(id);
43
+ return true;
44
+ }
45
+ catch {
46
+ return false;
47
+ }
48
+ }
49
+ // `export default` compiles to `exports.default`; `module.exports =` is returned as is.
50
+ function unwrapDefaultExport(loaded) {
51
+ return (loaded.default ?? loaded);
26
52
  }
27
53
  export function findAndReadConfigFile(startDir, configFileName) {
28
54
  let dir = startDir;
package/cli/package.json CHANGED
@@ -19,11 +19,15 @@
19
19
  "yauzl": "^3.2.0"
20
20
  },
21
21
  "peerDependencies": {
22
- "ts-node": ">=10"
22
+ "ts-node": ">=10",
23
+ "tsx": ">=4"
23
24
  },
24
25
  "peerDependenciesMeta": {
25
26
  "ts-node": {
26
27
  "optional": true
28
+ },
29
+ "tsx": {
30
+ "optional": true
27
31
  }
28
32
  },
29
33
  "engines": {
@@ -3,28 +3,62 @@ import path from "path";
3
3
  import { createRequire } from "module";
4
4
  import type { CliConfigInterface } from "../../typings/react-native-code-push.d.ts";
5
5
 
6
- const nodeRequire = createRequire(import.meta.url);
7
-
8
6
  /**
9
7
  * allows to require a config file with .ts extension
10
8
  */
11
9
  function requireConfig(filePath: string): CliConfigInterface {
12
10
  const ext = path.extname(filePath);
11
+ // Resolve the loader from the project the config file lives in, not from the CLI install.
12
+ const projectRequire = createRequire(filePath);
13
13
 
14
14
  if (ext === '.ts') {
15
- try {
16
- nodeRequire('ts-node/register');
17
- } catch {
18
- console.error('ts-node not found. Please install ts-node as a devDependency.');
19
- process.exit(1);
20
- }
15
+ return unwrapDefaultExport(requireTsConfig(projectRequire, filePath));
21
16
  } else if (ext === '.js') {
22
17
  // do nothing
23
18
  } else {
24
19
  throw new Error(`Unsupported file extension: ${ext}`);
25
20
  }
26
21
 
27
- return nodeRequire(filePath) as CliConfigInterface;
22
+ return unwrapDefaultExport(projectRequire(filePath));
23
+ }
24
+
25
+ /**
26
+ * tsx is preferred: it needs no tsconfig setup and resolves tsconfig `paths` aliases on its own.
27
+ * ts-node keeps working for projects that already have it configured.
28
+ */
29
+ function requireTsConfig(projectRequire: NodeRequire, filePath: string): { default?: unknown } {
30
+ if (canResolve(projectRequire, 'tsx/cjs/api')) {
31
+ const { require: tsxRequire } = projectRequire('tsx/cjs/api') as {
32
+ require: (id: string, fromFile: string) => { default?: unknown };
33
+ };
34
+ return tsxRequire(filePath, filePath);
35
+ }
36
+
37
+ if (canResolve(projectRequire, 'ts-node/register')) {
38
+ console.warn(
39
+ 'warn: Loading the config file with ts-node, which is no longer maintained. Support for it ' +
40
+ 'will be removed in a future major version - please install tsx instead (`npm i -D tsx`).',
41
+ );
42
+ projectRequire('ts-node/register');
43
+ return projectRequire(filePath);
44
+ }
45
+
46
+ console.error('A TypeScript config file needs a loader. Please install tsx as a devDependency (`npm i -D tsx`).');
47
+ process.exit(1);
48
+ }
49
+
50
+ function canResolve(projectRequire: NodeRequire, id: string): boolean {
51
+ try {
52
+ projectRequire.resolve(id);
53
+ return true;
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ // `export default` compiles to `exports.default`; `module.exports =` is returned as is.
60
+ function unwrapDefaultExport(loaded: { default?: unknown }): CliConfigInterface {
61
+ return (loaded.default ?? loaded) as CliConfigInterface;
28
62
  }
29
63
 
30
64
  export function findAndReadConfigFile(startDir: string, configFileName: string): CliConfigInterface {
@@ -36,6 +36,10 @@
36
36
  @interface CodePush : RCTEventEmitter
37
37
  #endif
38
38
 
39
+ /*
40
+ * Returns the bundle embedded in the binary, without resolving an installed update.
41
+ * The update pipeline uses this as the base for binary patches and asset diffs.
42
+ */
39
43
  + (NSURL *)binaryBundleURL;
40
44
  /*
41
45
  * This method is used to retrieve the URL for the most recent
@@ -64,8 +68,10 @@
64
68
  subdirectory:(NSString *)resourceSubdirectory
65
69
  bundle:(NSBundle *)resourceBundle;
66
70
 
71
+ /* Internal storage root used by CodePushPackage. */
67
72
  + (NSString *)getApplicationSupportDirectory;
68
73
 
74
+ /* Returns the embedded assets path used as the base for asset diffs. */
69
75
  + (NSString *)bundleAssetsPath;
70
76
 
71
77
  /*
@@ -76,9 +82,8 @@
76
82
  + (void)overrideAppVersion:(NSString *)appVersion;
77
83
 
78
84
  /*
79
- * This method allows dynamically setting the app's
80
- * deployment key, in addition to setting it via
81
- * the Info.plist file's CodePushDeploymentKey setting.
85
+ * Retained for compatibility. The current implementation ignores the argument
86
+ * and uses the deprecated deployment key placeholder.
82
87
  */
83
88
  + (void)setDeploymentKey:(NSString *)deploymentKey;
84
89
 
@@ -1,6 +1,7 @@
1
1
  #import <React/RCTAssert.h>
2
2
  #import <React/RCTBridgeModule.h>
3
3
  #import <React/RCTConvert.h>
4
+ #import <React/RCTInvalidating.h>
4
5
  #import <React/RCTEventDispatcher.h>
5
6
  #import <React/RCTRootView.h>
6
7
  #import <React/RCTUtils.h>
@@ -8,7 +9,9 @@
8
9
 
9
10
  #import "CodePush.h"
10
11
 
11
- @interface CodePush () <RCTFrameUpdateObserver
12
+ #include <atomic>
13
+
14
+ @interface CodePush () <RCTFrameUpdateObserver, RCTInvalidating
12
15
  #ifdef RCT_NEW_ARCH_ENABLED
13
16
  , NativeCodePushSpec
14
17
  #endif
@@ -32,6 +35,17 @@
32
35
  BOOL _allowed;
33
36
  BOOL _restartInProgress;
34
37
  NSMutableArray *_restartQueue;
38
+
39
+ /*
40
+ * Whether the React Native instance this module was created for is still the one
41
+ * running.
42
+ *
43
+ * A reload starts the next instance before the one being replaced has finished being
44
+ * torn down, so work this instance started can still be running with nothing left to
45
+ * hand its result to. Read from whichever thread that work is on, written when the
46
+ * instance is invalidated, so it is atomic.
47
+ */
48
+ std::atomic<bool> _generationAlive;
35
49
  }
36
50
 
37
51
  RCT_EXPORT_MODULE()
@@ -307,7 +321,42 @@ static NSString *const LatestRollbackCountKey = @"count";
307
321
  [[NSNotificationCenter defaultCenter] removeObserver:self];
308
322
  }
309
323
 
324
+ /*
325
+ * Called when the React Native instance this module belongs to is being replaced.
326
+ *
327
+ * A download in flight keeps this module alive past that point, and the suspend timer and
328
+ * the resume notifications keep it reachable too. None of them may act afterwards: the
329
+ * runtime they would report to is gone, and the restart they would trigger belongs to a
330
+ * generation that is no longer on screen.
331
+ */
332
+ - (void)invalidate
333
+ {
334
+ _generationAlive.store(false);
335
+
336
+ [_appSuspendTimer invalidate];
337
+ _appSuspendTimer = nil;
338
+ _hasResumeListener = NO;
339
+ [[NSNotificationCenter defaultCenter] removeObserver:self];
340
+
341
+ #ifndef RCT_NEW_ARCH_ENABLED
342
+ [super invalidate];
343
+ #endif
344
+ }
345
+
346
+ /* Whether this module still belongs to the React Native instance that is running. */
347
+ - (BOOL)isGenerationAlive
348
+ {
349
+ return _generationAlive.load();
350
+ }
351
+
310
352
  - (void)dispatchDownloadProgressEvent {
353
+ // The download that is reporting here can be one this module started before the app was
354
+ // reloaded, in which case the event emitter it would reach belongs to a runtime that has
355
+ // been torn down.
356
+ if (![self isGenerationAlive]) {
357
+ return;
358
+ }
359
+
311
360
  // Notify the script-side about the progress
312
361
  NSDictionary *progress = @{
313
362
  @"totalBytes" : [NSNumber numberWithLongLong:_latestExpectedContentLength],
@@ -375,6 +424,7 @@ static NSString *const LatestRollbackCountKey = @"count";
375
424
  _restartInProgress = NO;
376
425
  _restartQueue = [NSMutableArray arrayWithCapacity:1];
377
426
  _lastProgressEventTime = 0;
427
+ _generationAlive.store(true);
378
428
 
379
429
  self = [super init];
380
430
  if (self) {
@@ -533,6 +583,11 @@ static NSString *const LatestRollbackCountKey = @"count";
533
583
  */
534
584
  - (void)loadBundle
535
585
  {
586
+ if (![self isGenerationAlive]) {
587
+ CPLog(@"Ignoring a reload requested by a React Native instance that is no longer running.");
588
+ return;
589
+ }
590
+
536
591
  @synchronized([CodePush class]) {
537
592
  hasInitializedUpdateAfterRestartForCurrentLoad = NO;
538
593
  }
@@ -802,6 +857,11 @@ RCT_EXPORT_METHOD(downloadUpdate:(NSDictionary*)updatePackage
802
857
 
803
858
  - (void)restartAppInternal:(BOOL)onlyIfUpdateIsPending
804
859
  {
860
+ if (![self isGenerationAlive]) {
861
+ CPLog(@"Ignoring a restart requested by a React Native instance that is no longer running.");
862
+ return;
863
+ }
864
+
805
865
  if (_restartInProgress) {
806
866
  CPLog(@"Restart request queued until the current restart is completed.");
807
867
  [_restartQueue addObject:@(onlyIfUpdateIsPending)];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bravemobile/react-native-code-push",
3
- "version": "13.1.0-beta.6",
3
+ "version": "13.2.0",
4
4
  "description": "React Native plugin for the CodePush service",
5
5
  "main": "src/CodePush.js",
6
6
  "react-native": "src/CodePush.js",
@@ -71,8 +71,8 @@
71
71
  ],
72
72
  "scripts": {
73
73
  "setup": "npm install --quiet --no-progress",
74
- "setup-example-app": "ts-node --project scripts/setupExampleApp/tsconfig.json scripts/setupExampleApp/runSetupExampleApp.ts",
75
- "setup-expo-example-app": "ts-node --project scripts/setupExampleApp/tsconfig.json scripts/setupExpoExampleApp/runSetupExpoExampleApp.ts",
74
+ "setup-example-app": "tsx scripts/setupExampleApp/runSetupExampleApp.ts",
75
+ "setup-expo-example-app": "tsx scripts/setupExpoExampleApp/runSetupExpoExampleApp.ts",
76
76
  "type:src": "tsc --project tsconfig.json --noEmit",
77
77
  "type:cli": "npm run --workspace cli typecheck",
78
78
  "type:scripts": "tsc --project scripts/tsconfig.json --noEmit",
@@ -82,7 +82,7 @@
82
82
  "prepack": "npm run build:cli",
83
83
  "eslint": "eslint --quiet .",
84
84
  "jest": "jest src/CodePush.test.js src/versioning/* expo/* && npm run --workspace cli test",
85
- "e2e": "ts-node --project e2e/tsconfig.json e2e/run.ts"
85
+ "e2e": "tsx e2e/run.ts"
86
86
  },
87
87
  "publishConfig": {
88
88
  "access": "public"
@@ -102,11 +102,19 @@
102
102
  },
103
103
  "peerDependencies": {
104
104
  "expo": ">=50.0.0",
105
- "react-native": "*"
105
+ "react-native": "*",
106
+ "ts-node": ">=10",
107
+ "tsx": ">=4"
106
108
  },
107
109
  "peerDependenciesMeta": {
108
110
  "expo": {
109
111
  "optional": true
112
+ },
113
+ "ts-node": {
114
+ "optional": true
115
+ },
116
+ "tsx": {
117
+ "optional": true
110
118
  }
111
119
  },
112
120
  "devDependencies": {
@@ -128,7 +136,7 @@
128
136
  "jest": "^29.7.0",
129
137
  "react": "19.2.3",
130
138
  "react-native": "0.84.1",
131
- "ts-node": "^10.9.2",
139
+ "tsx": "^4.23.13",
132
140
  "typescript": "5.0.4",
133
141
  "typescript-eslint": "^8.11.0"
134
142
  },
package/src/CodePush.js CHANGED
@@ -2,6 +2,7 @@ import { Alert, AppState, Platform } from "react-native";
2
2
  import log from "./logging";
3
3
  import hoistStatics from 'hoist-non-react-statics';
4
4
  import { SemverVersioning } from './versioning/SemverVersioning'
5
+ import invokeTelemetryCallback from './invokeTelemetryCallback';
5
6
 
6
7
  const NativeCodePushModule = require("./native/NativeCodePush");
7
8
  let NativeCodePush = NativeCodePushModule.getNativeCodePush?.() ?? NativeCodePushModule.default;
@@ -83,7 +84,7 @@ async function decideLatestReleaseIsInRollout(versioning, clientId, onRolloutSki
83
84
 
84
85
  if (!inRollout) {
85
86
  log(`Skipping update due to rollout. Bucket ${bucket} is not smaller than rollout range ${latestReleaseInfo.rollout}.`);
86
- onRolloutSkipped?.(latestVersion);
87
+ invokeTelemetryCallback('onRolloutSkipped', onRolloutSkipped, latestVersion);
87
88
  }
88
89
 
89
90
  return inRollout;
@@ -378,11 +379,11 @@ async function tryReportStatus(statusReport, retryOnAppResume) {
378
379
  const label = statusReport.package.label;
379
380
  if (statusReport.status === "DeploymentSucceeded") {
380
381
  log(`Reporting CodePush update success (${label})`);
381
- sharedCodePushOptions?.onUpdateSuccess?.(label);
382
+ invokeTelemetryCallback('onUpdateSuccess', sharedCodePushOptions?.onUpdateSuccess, label);
382
383
  } else {
383
384
  log(`Reporting CodePush update rollback (${label})`);
384
385
  await nativeCodePush.setLatestRollbackInfo(statusReport.package.packageHash);
385
- sharedCodePushOptions?.onUpdateRollback?.(label);
386
+ invokeTelemetryCallback('onUpdateRollback', sharedCodePushOptions?.onUpdateRollback, label);
386
387
  }
387
388
  }
388
389
 
@@ -565,20 +566,8 @@ async function syncInternal(options = {}, syncStatusChangeCallback, downloadProg
565
566
  ...options,
566
567
  };
567
568
 
568
- /*
569
- * A callback an app registered to observe how the update archives went must not be able
570
- * to cost it an update, so it is isolated the way the other sync callbacks are: whatever
571
- * it throws is logged and the install carries on. An app that registered none leaves the
572
- * download exactly as it was before this option existed.
573
- */
574
569
  const onUpdateArchiveResult = typeof syncOptions.onUpdateArchiveResult === "function"
575
- ? (label, result) => {
576
- try {
577
- syncOptions.onUpdateArchiveResult(label, result);
578
- } catch (error) {
579
- log(`An error has occurred : ${error.stack}`);
580
- }
581
- }
570
+ ? (label, result) => invokeTelemetryCallback('onUpdateArchiveResult', syncOptions.onUpdateArchiveResult, label, result)
582
571
  : null;
583
572
 
584
573
  syncStatusChangeCallback = typeof syncStatusChangeCallback === "function"
@@ -630,14 +619,14 @@ async function syncInternal(options = {}, syncStatusChangeCallback, downloadProg
630
619
 
631
620
  const doDownloadAndInstall = async () => {
632
621
  syncStatusChangeCallback(CodePush.SyncStatus.DOWNLOADING_PACKAGE);
633
- sharedCodePushOptions.onDownloadStart?.(remotePackageLabel);
622
+ invokeTelemetryCallback('onDownloadStart', sharedCodePushOptions.onDownloadStart, remotePackageLabel);
634
623
 
635
624
  const localPackage = await remotePackage.download(
636
625
  downloadProgressCallback,
637
626
  onUpdateArchiveResult && ((result) => onUpdateArchiveResult(remotePackageLabel, result)),
638
627
  );
639
628
 
640
- sharedCodePushOptions.onDownloadSuccess?.(remotePackageLabel);
629
+ invokeTelemetryCallback('onDownloadSuccess', sharedCodePushOptions.onDownloadSuccess, remotePackageLabel);
641
630
 
642
631
  // Determine the correct install mode based on whether the update is mandatory or not.
643
632
  resolvedInstallMode = localPackage.isMandatory ? syncOptions.mandatoryInstallMode : syncOptions.installMode;
@@ -721,7 +710,7 @@ async function syncInternal(options = {}, syncStatusChangeCallback, downloadProg
721
710
  }
722
711
  } catch (error) {
723
712
  syncStatusChangeCallback(CodePush.SyncStatus.UNKNOWN_ERROR);
724
- sharedCodePushOptions?.onSyncError?.(remotePackageLabel ?? 'unknown', error);
713
+ invokeTelemetryCallback('onSyncError', sharedCodePushOptions?.onSyncError, remotePackageLabel ?? 'unknown', error);
725
714
  log(error.message);
726
715
  throw error;
727
716
  }
@@ -752,26 +741,26 @@ let CodePush;
752
741
  * updateChecker: updateChecker | undefined,
753
742
  * setUpdateChecker(updateCheckerFunction: updateChecker | undefined): void,
754
743
  *
755
- * onUpdateSuccess: (label: string) => void | undefined,
756
- * setOnUpdateSuccess(onUpdateSuccessFunction: (label: string) => void | undefined): void,
744
+ * onUpdateSuccess: (label: string) => void | Promise<void> | undefined,
745
+ * setOnUpdateSuccess(onUpdateSuccessFunction: (label: string) => void | Promise<void> | undefined): void,
757
746
  *
758
- * onUpdateRollback: (label: string) => void | undefined,
759
- * setOnUpdateRollback(onUpdateRollbackFunction: (label: string) => void | undefined): void,
747
+ * onUpdateRollback: (label: string) => void | Promise<void> | undefined,
748
+ * setOnUpdateRollback(onUpdateRollbackFunction: (label: string) => void | Promise<void> | undefined): void,
760
749
  *
761
- * onDownloadStart: (label: string) => void | undefined,
762
- * setOnDownloadStart(onDownloadStartFunction: (label: string) => void | undefined): void,
750
+ * onDownloadStart: (label: string) => void | Promise<void> | undefined,
751
+ * setOnDownloadStart(onDownloadStartFunction: (label: string) => void | Promise<void> | undefined): void,
763
752
  *
764
- * onDownloadSuccess: (label: string) => void | undefined,
765
- * setOnDownloadSuccess(onDownloadSuccessFunction: (label: string) => void | undefined): void,
753
+ * onDownloadSuccess: (label: string) => void | Promise<void> | undefined,
754
+ * setOnDownloadSuccess(onDownloadSuccessFunction: (label: string) => void | Promise<void> | undefined): void,
766
755
  *
767
- * onSyncError: (label: string, error: Error) => void | undefined,
768
- * setOnSyncError(onSyncErrorFunction: (label: string, error: Error) => void | undefined): void,
756
+ * onSyncError: (label: string, error: Error) => void | Promise<void> | undefined,
757
+ * setOnSyncError(onSyncErrorFunction: (label: string, error: Error) => void | Promise<void> | undefined): void,
769
758
  *
770
- * onRolloutSkipped: (label: string, error: Error) => void | undefined,
771
- * setOnRolloutSkipped(onRolloutSkippedFunction: (label: string, error: Error) => void | undefined): void,
759
+ * onRolloutSkipped: (label: string, error: Error) => void | Promise<void> | undefined,
760
+ * setOnRolloutSkipped(onRolloutSkippedFunction: (label: string, error: Error) => void | Promise<void> | undefined): void,
772
761
  *
773
- * onUpdateArchiveResult: (label: string, result: object) => void | undefined,
774
- * setOnUpdateArchiveResult(onUpdateArchiveResultFunction: (label: string, result: object) => void | undefined): void,
762
+ * onUpdateArchiveResult: (label: string, result: object) => void | Promise<void> | undefined,
763
+ * setOnUpdateArchiveResult(onUpdateArchiveResultFunction: (label: string, result: object) => void | Promise<void> | undefined): void,
775
764
  * }}
776
765
  */
777
766
  const sharedCodePushOptions = {
@@ -64,7 +64,7 @@ function diffRelease() {
64
64
  * which only a download that had an archive to try comes back with.
65
65
  * @param installedPackage the CodePush update the app is running, if any.
66
66
  */
67
- function createNativeBridge({ updateArchiveResult, installedPackage } = {}) {
67
+ function createNativeBridge({ updateArchiveResult, installedPackage, statusReport } = {}) {
68
68
  return {
69
69
  addDownloadProgressListener: jest.fn(() => ({ remove: jest.fn() })),
70
70
  downloadUpdate: jest.fn(async (updatePackage) => ({
@@ -78,9 +78,12 @@ function createNativeBridge({ updateArchiveResult, installedPackage } = {}) {
78
78
  // The rest of what a full `sync()` reaches for.
79
79
  InstallMode,
80
80
  clearPendingRestart: jest.fn(),
81
- getNewStatusReport: jest.fn(async () => null),
81
+ getNewStatusReport: jest.fn(async () => statusReport ?? null),
82
82
  installUpdate: jest.fn(async () => {}),
83
83
  notifyApplicationReady: jest.fn(async () => {}),
84
+ recordStatusReported: jest.fn(),
85
+ saveStatusReportForRetry: jest.fn(),
86
+ setLatestRollbackInfo: jest.fn(async () => {}),
84
87
  };
85
88
  }
86
89
 
@@ -89,13 +92,13 @@ function createNativeBridge({ updateArchiveResult, installedPackage } = {}) {
89
92
  * native bridge live on the module itself - configured the way an app configures it: the
90
93
  * decorator registers the app-wide options, and every sync below is one the app asks for.
91
94
  *
92
- * @param onUpdateArchiveResult a callback the app registers on the decorator, as opposed to
93
- * one it passes to a single `sync()` call.
95
+ * Callback options left after the bridge setup values are removed are registered on the
96
+ * decorator, the same way an app registers them.
94
97
  */
95
- function loadCodePush({ releaseHistory = {}, updateChecker, updateArchiveResult, onUpdateArchiveResult, installedPackage } = {}) {
98
+ function loadCodePush({ releaseHistory = {}, updateChecker, updateArchiveResult, installedPackage, statusReport, ...codePushOptions } = {}) {
96
99
  jest.resetModules();
97
100
  const CodePush = require('./CodePush');
98
- const nativeBridge = createNativeBridge({ updateArchiveResult, installedPackage });
101
+ const nativeBridge = createNativeBridge({ updateArchiveResult, installedPackage, statusReport });
99
102
 
100
103
  CodePush.setUpTestDependencies(
101
104
  null,
@@ -106,7 +109,7 @@ function loadCodePush({ releaseHistory = {}, updateChecker, updateArchiveResult,
106
109
  checkFrequency: CodePush.CheckFrequency.MANUAL,
107
110
  releaseHistoryFetcher: async () => releaseHistory,
108
111
  updateChecker,
109
- onUpdateArchiveResult,
112
+ ...codePushOptions,
110
113
  });
111
114
 
112
115
  return { CodePush, nativeBridge };
@@ -492,6 +495,40 @@ describe('the update archive result of a sync', () => {
492
495
  expect(localPackage).toMatchObject({ label: LABEL, packageHash: PACKAGE_HASH });
493
496
  });
494
497
 
498
+ it('installs the update when the callback returns a rejected Promise', async () => {
499
+ const { CodePush, nativeBridge } = loadCodePush({
500
+ releaseHistory: patchedRelease(),
501
+ updateArchiveResult: APPLIED,
502
+ });
503
+ const onUpdateArchiveResult = jest.fn(async () => {
504
+ throw new Error('the telemetry request failed');
505
+ });
506
+
507
+ const syncStatus = await CodePush.sync({ onUpdateArchiveResult });
508
+ await Promise.resolve();
509
+
510
+ expect(syncStatus).toBe(CodePush.SyncStatus.UPDATE_INSTALLED);
511
+ expect(onUpdateArchiveResult).toHaveBeenCalledWith(LABEL, APPLIED);
512
+ expect(nativeBridge.installUpdate).toHaveBeenCalledTimes(1);
513
+ });
514
+
515
+ it('resolves a direct download when the callback returns a rejected Promise', async () => {
516
+ const { CodePush } = loadCodePush({
517
+ releaseHistory: patchedRelease(),
518
+ updateArchiveResult: APPLIED,
519
+ });
520
+ const onUpdateArchiveResult = jest.fn(async () => {
521
+ throw new Error('the telemetry request failed');
522
+ });
523
+
524
+ const remotePackage = await CodePush.checkForUpdate();
525
+ const localPackage = await remotePackage.download(undefined, onUpdateArchiveResult);
526
+ await Promise.resolve();
527
+
528
+ expect(onUpdateArchiveResult).toHaveBeenCalledWith(APPLIED);
529
+ expect(localPackage).toMatchObject({ label: LABEL, packageHash: PACKAGE_HASH });
530
+ });
531
+
495
532
  it('leaves the result off the package a download resolves with', async () => {
496
533
  const { CodePush } = loadCodePush({
497
534
  releaseHistory: patchedRelease(),
@@ -505,3 +542,110 @@ describe('the update archive result of a sync', () => {
505
542
  expect(localPackage).not.toHaveProperty('updateArchiveResult');
506
543
  });
507
544
  });
545
+
546
+ describe('telemetry callback errors', () => {
547
+ it('installs the update when onDownloadStart throws', async () => {
548
+ const onDownloadStart = jest.fn(() => {
549
+ throw new Error('the telemetry client is unavailable');
550
+ });
551
+ const { CodePush, nativeBridge } = loadCodePush({
552
+ releaseHistory: fullOnlyRelease(),
553
+ onDownloadStart,
554
+ });
555
+
556
+ const syncStatus = await CodePush.sync();
557
+
558
+ expect(syncStatus).toBe(CodePush.SyncStatus.UPDATE_INSTALLED);
559
+ expect(onDownloadStart).toHaveBeenCalledWith(LABEL);
560
+ expect(nativeBridge.installUpdate).toHaveBeenCalledTimes(1);
561
+ });
562
+
563
+ it('installs the update when onDownloadSuccess returns a rejected Promise', async () => {
564
+ const onDownloadSuccess = jest.fn(async () => {
565
+ throw new Error('the telemetry request failed');
566
+ });
567
+ const { CodePush, nativeBridge } = loadCodePush({
568
+ releaseHistory: fullOnlyRelease(),
569
+ onDownloadSuccess,
570
+ });
571
+
572
+ const syncStatus = await CodePush.sync();
573
+ await Promise.resolve();
574
+
575
+ expect(syncStatus).toBe(CodePush.SyncStatus.UPDATE_INSTALLED);
576
+ expect(onDownloadSuccess).toHaveBeenCalledWith(LABEL);
577
+ expect(nativeBridge.installUpdate).toHaveBeenCalledTimes(1);
578
+ });
579
+
580
+ it('uses the next release when onRolloutSkipped throws', async () => {
581
+ const onRolloutSkipped = jest.fn(() => {
582
+ throw new Error('the telemetry client is unavailable');
583
+ });
584
+ const { CodePush } = loadCodePush({
585
+ releaseHistory: {
586
+ ...fullOnlyRelease(),
587
+ '1.0.2': {
588
+ ...fullOnlyRelease()[LABEL],
589
+ rollout: 0,
590
+ packageHash: 'b'.repeat(64),
591
+ },
592
+ },
593
+ onRolloutSkipped,
594
+ });
595
+
596
+ const remotePackage = await CodePush.checkForUpdate();
597
+
598
+ expect(onRolloutSkipped).toHaveBeenCalledWith('1.0.2');
599
+ expect(remotePackage.label).toBe(LABEL);
600
+ });
601
+
602
+ it('rejects with the original error when onSyncError throws', async () => {
603
+ const downloadError = new Error('the update download failed');
604
+ const onSyncError = jest.fn(() => {
605
+ throw new Error('the telemetry client is unavailable');
606
+ });
607
+ const { CodePush, nativeBridge } = loadCodePush({
608
+ releaseHistory: fullOnlyRelease(),
609
+ onSyncError,
610
+ });
611
+ nativeBridge.downloadUpdate.mockRejectedValueOnce(downloadError);
612
+
613
+ await expect(CodePush.sync()).rejects.toBe(downloadError);
614
+ expect(onSyncError).toHaveBeenCalledWith(LABEL, downloadError);
615
+ });
616
+
617
+ it('records a successful update when onUpdateSuccess throws', async () => {
618
+ const statusReport = {
619
+ status: 'DeploymentSucceeded',
620
+ package: { label: LABEL, packageHash: PACKAGE_HASH },
621
+ };
622
+ const onUpdateSuccess = jest.fn(() => {
623
+ throw new Error('the telemetry client is unavailable');
624
+ });
625
+ const { CodePush, nativeBridge } = loadCodePush({ statusReport, onUpdateSuccess });
626
+
627
+ await CodePush.notifyAppReady();
628
+
629
+ expect(onUpdateSuccess).toHaveBeenCalledWith(LABEL);
630
+ expect(nativeBridge.recordStatusReported).toHaveBeenCalledWith(statusReport);
631
+ expect(nativeBridge.saveStatusReportForRetry).not.toHaveBeenCalled();
632
+ });
633
+
634
+ it('records a rollback when onUpdateRollback returns a rejected Promise', async () => {
635
+ const statusReport = {
636
+ status: 'DeploymentFailed',
637
+ package: { label: LABEL, packageHash: PACKAGE_HASH },
638
+ };
639
+ const onUpdateRollback = jest.fn(async () => {
640
+ throw new Error('the telemetry request failed');
641
+ });
642
+ const { CodePush, nativeBridge } = loadCodePush({ statusReport, onUpdateRollback });
643
+
644
+ await CodePush.notifyAppReady();
645
+ await Promise.resolve();
646
+
647
+ expect(onUpdateRollback).toHaveBeenCalledWith(LABEL);
648
+ expect(nativeBridge.recordStatusReported).toHaveBeenCalledWith(statusReport);
649
+ expect(nativeBridge.saveStatusReportForRetry).not.toHaveBeenCalled();
650
+ });
651
+ });
@@ -0,0 +1,18 @@
1
+ const log = require("./logging");
2
+
3
+ module.exports = function invokeTelemetryCallback(name, callback, ...args) {
4
+ if (typeof callback !== "function") return;
5
+
6
+ const logError = (error) => {
7
+ log(`The ${name} callback failed: ${error?.message ?? error}`);
8
+ };
9
+
10
+ try {
11
+ const result = callback(...args);
12
+ if (result && typeof result.then === "function") {
13
+ Promise.resolve(result).catch(logError);
14
+ }
15
+ } catch (error) {
16
+ logError(error);
17
+ }
18
+ };
@@ -1,4 +1,4 @@
1
- const log = require("./logging");
1
+ const invokeTelemetryCallback = require("./invokeTelemetryCallback");
2
2
 
3
3
  // This function is used to augment remote and local
4
4
  // package objects with additional functionality/properties
@@ -42,14 +42,10 @@ module.exports = (NativeCodePush) => {
42
42
  // taken off the package and reported on its own, leaving the package exactly what
43
43
  // the native side saved.
44
44
  const { updateArchiveResult, ...downloadedPackage } = downloadResult ?? {};
45
- if (updateArchiveResult && updateArchiveResultCallback) {
45
+ if (updateArchiveResult) {
46
46
  // The result is an observation, so an observer that throws must not turn a
47
47
  // downloaded update into a failed one.
48
- try {
49
- updateArchiveResultCallback(updateArchiveResult);
50
- } catch (error) {
51
- log(`The update archive result callback threw: ${error?.message ?? error}`);
52
- }
48
+ invokeTelemetryCallback('onUpdateArchiveResult', updateArchiveResultCallback, updateArchiveResult);
53
49
  }
54
50
 
55
51
  return { ...downloadedPackage, ...local };
@@ -211,27 +211,27 @@ export interface CodePushOptions extends SyncOptions {
211
211
  /**
212
212
  * Callback function that is called when the update installation succeeds.
213
213
  */
214
- onUpdateSuccess?: (label: string) => void;
214
+ onUpdateSuccess?: (label: string) => void | Promise<void>;
215
215
  /**
216
216
  * Callback function that is called when the update rolled back.
217
217
  */
218
- onUpdateRollback?: (label: string) => void;
218
+ onUpdateRollback?: (label: string) => void | Promise<void>;
219
219
  /**
220
220
  * Callback function that is called when download starts.
221
221
  */
222
- onDownloadStart?: (label: string) => void;
222
+ onDownloadStart?: (label: string) => void | Promise<void>;
223
223
  /**
224
224
  * Callback function that is called when download finished successfully.
225
225
  */
226
- onDownloadSuccess?: (label: string) => void;
226
+ onDownloadSuccess?: (label: string) => void | Promise<void>;
227
227
  /**
228
228
  * Callback function that is called when sync process failed.
229
229
  */
230
- onSyncError?: (label: string, error: Error) => void;
230
+ onSyncError?: (label: string, error: Error) => void | Promise<void>;
231
231
  /**
232
232
  * Callback function that is called when rollout is skipped.
233
233
  */
234
- onRolloutSkipped?: (label: string, error: Error) => void;
234
+ onRolloutSkipped?: (label: string, error: Error) => void | Promise<void>;
235
235
  }
236
236
 
237
237
  export interface DownloadProgress {
@@ -321,7 +321,7 @@ export interface RemotePackage extends Package {
321
321
  */
322
322
  download(
323
323
  downloadProgressCallback?: DownloadProgressCallback,
324
- updateArchiveResultCallback?: (result: UpdateArchiveResult) => void,
324
+ updateArchiveResultCallback?: (result: UpdateArchiveResult) => void | Promise<void>,
325
325
  ): Promise<LocalPackage>;
326
326
 
327
327
  /**
@@ -404,7 +404,7 @@ export interface SyncOptions {
404
404
  * depends on the callback: registering none changes nothing, and one that throws is
405
405
  * logged and does not fail the download it is reporting on.
406
406
  */
407
- onUpdateArchiveResult?: (label: string, result: UpdateArchiveResult) => void;
407
+ onUpdateArchiveResult?: (label: string, result: UpdateArchiveResult) => void | Promise<void>;
408
408
 
409
409
  /**
410
410
  * Specifies whether to ignore the update if the installation fails.