@bravemobile/react-native-code-push 13.1.0 → 13.3.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.
Files changed (31) hide show
  1. package/README.md +18 -1
  2. package/android/app/proguard-rules.pro +0 -7
  3. package/android/app/src/main/java/com/microsoft/codepush/react/CodePush.java +3 -0
  4. package/android/app/src/main/java/com/microsoft/codepush/react/CodePushNativeModule.java +25 -0
  5. package/cli/commands/createHistoryCommand/createReleaseHistory.test.ts +2 -2
  6. package/cli/commands/releaseCommand/addToReleaseHistory.ts +6 -0
  7. package/cli/commands/releaseCommand/index.test.ts +29 -0
  8. package/cli/commands/releaseCommand/index.ts +9 -0
  9. package/cli/commands/releaseCommand/release.test.ts +28 -0
  10. package/cli/commands/releaseCommand/release.ts +2 -0
  11. package/cli/commands/updateHistoryCommand/index.test.ts +129 -0
  12. package/cli/commands/updateHistoryCommand/index.ts +28 -2
  13. package/cli/commands/updateHistoryCommand/updateReleaseHistory.ts +3 -0
  14. package/cli/dist/commands/createHistoryCommand/createReleaseHistory.test.js +2 -2
  15. package/cli/dist/commands/releaseCommand/addToReleaseHistory.js +5 -1
  16. package/cli/dist/commands/releaseCommand/index.js +7 -1
  17. package/cli/dist/commands/releaseCommand/index.test.js +22 -0
  18. package/cli/dist/commands/releaseCommand/release.js +2 -2
  19. package/cli/dist/commands/releaseCommand/release.test.js +18 -1
  20. package/cli/dist/commands/updateHistoryCommand/index.js +23 -2
  21. package/cli/dist/commands/updateHistoryCommand/index.test.js +102 -0
  22. package/cli/dist/commands/updateHistoryCommand/updateReleaseHistory.js +4 -1
  23. package/cli/dist/utils/fsUtils.js +35 -9
  24. package/cli/package.json +5 -1
  25. package/cli/utils/fsUtils.ts +43 -9
  26. package/ios/CodePush/CodePush.h +8 -3
  27. package/ios/CodePush/CodePush.mm +61 -1
  28. package/package.json +14 -6
  29. package/src/CodePush.js +16 -3
  30. package/src/CodePush.test.js +57 -0
  31. package/typings/react-native-code-push.d.ts +20 -0
@@ -95,7 +95,7 @@ async function runRelease(staged, overrides = {}) {
95
95
  await release(recordingUploader(uploads, overrides.uploadFailsFor), history.getReleaseHistory, history.setReleaseHistory, BINARY_VERSION, APP_VERSION, undefined, overrides.platform ?? 'ios', undefined, staged.outputPath, 'index.ts',
96
96
  // Left unset unless the case is about -j, the way commander leaves it.
97
97
  overrides.jsBundleName, false, true, undefined, true, // skipBundle
98
- overrides.skipCleanup ?? true, staged.bundleDirectory, undefined, undefined, overrides.binaryBundlePath, overrides.onOversizedPatch, overrides.bundleDownloader, overrides.diffBaseCount);
98
+ overrides.skipCleanup ?? true, staged.bundleDirectory, undefined, undefined, overrides.binaryBundlePath, overrides.onOversizedPatch, overrides.bundleDownloader, overrides.diffBaseCount, overrides.minimumBackgroundDuration);
99
99
  return { uploads, releaseHistories: history.saved, uploadCountsWhenHistorySaved };
100
100
  }
101
101
  let logs;
@@ -563,3 +563,20 @@ describe("release with asset diff bases", () => {
563
563
  expect(path.basename(uploads[0].filePath)).toBe(staged.bundleFileName);
564
564
  });
565
565
  });
566
+ describe("release --minimum-background-duration", () => {
567
+ it("records the background wait on the release it publishes", async () => {
568
+ const staged = await stageBundleOutput("minimum-background-duration");
569
+ const { releaseHistories } = await runRelease(staged, { minimumBackgroundDuration: 600 });
570
+ expect(releaseHistories[0][APP_VERSION].minimumBackgroundDuration).toBe(600);
571
+ });
572
+ it("releases a background wait of zero seconds as zero, not as an unset option", async () => {
573
+ const staged = await stageBundleOutput("zero-minimum-background-duration");
574
+ const { releaseHistories } = await runRelease(staged, { minimumBackgroundDuration: 0 });
575
+ expect(releaseHistories[0][APP_VERSION].minimumBackgroundDuration).toBe(0);
576
+ });
577
+ it("leaves the release saying nothing about the background wait when the option is not given", async () => {
578
+ const staged = await stageBundleOutput("no-minimum-background-duration");
579
+ const { releaseHistories } = await runRelease(staged);
580
+ expect(releaseHistories[0][APP_VERSION]).not.toHaveProperty('minimumBackgroundDuration');
581
+ });
582
+ });
@@ -12,14 +12,35 @@ program.command('update-history')
12
12
  .option('-m, --mandatory <bool>', 'make the release to be mandatory', parseBoolean, undefined)
13
13
  .option('-e, --enable <bool>', 'make the release to be enabled', parseBoolean, undefined)
14
14
  .option('--rollout <number>', 'rollout percentage (0-100)', parseFloat, undefined)
15
+ .option('--minimum-background-duration <seconds>', 'seconds the app must have been in the background before this update is applied on resume. Overrides the minimumBackgroundDuration sync option.', parseDecimalInt, undefined)
15
16
  .action(async (options) => {
16
17
  const config = findAndReadConfigFile(process.cwd(), options.config);
17
- if (typeof options.mandatory !== "boolean" && typeof options.enable !== "boolean") {
18
+ if (typeof options.mandatory !== "boolean"
19
+ && typeof options.enable !== "boolean"
20
+ && typeof options.rollout !== "number"
21
+ && typeof options.minimumBackgroundDuration !== "number") {
18
22
  console.error('No options specified. Exiting the program.');
19
23
  process.exit(1);
20
24
  }
21
- await updateReleaseHistory(options.appVersion, options.binaryVersion, config.getReleaseHistory, config.setReleaseHistory, options.platform, options.identifier, options.mandatory, options.enable, options.rollout);
25
+ // `Number.isFinite` is what rejects a non-numeric `--rollout`: `parseFloat` turns it into
26
+ // NaN, and both `NaN < 0` and `NaN > 100` are false.
27
+ if (options.rollout !== undefined
28
+ && (!Number.isFinite(options.rollout) || options.rollout < 0 || options.rollout > 100)) {
29
+ console.error('Rollout percentage number must be between 0 and 100 (inclusive).');
30
+ process.exit(1);
31
+ }
32
+ if (options.minimumBackgroundDuration !== undefined
33
+ && (!Number.isInteger(options.minimumBackgroundDuration) || options.minimumBackgroundDuration < 0)) {
34
+ console.error('--minimum-background-duration must be a whole number of seconds, 0 or greater.');
35
+ process.exit(1);
36
+ }
37
+ await updateReleaseHistory(options.appVersion, options.binaryVersion, config.getReleaseHistory, config.setReleaseHistory, options.platform, options.identifier, options.mandatory, options.enable, options.rollout, options.minimumBackgroundDuration);
22
38
  });
39
+ // Not `parseInt` itself: commander hands a coercion function the current value as its
40
+ // second argument, which `parseInt` reads as the radix.
41
+ function parseDecimalInt(value) {
42
+ return parseInt(value, 10);
43
+ }
23
44
  function parseBoolean(value) {
24
45
  if (value === 'true')
25
46
  return true;
@@ -0,0 +1,102 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals";
4
+ /**
5
+ * Checks the command definition against the entry it saves. Everything this command does
6
+ * ends up in the release history the config is handed, so an option that never reaches it
7
+ * leaves the release exactly as it was - which the command still reports as a success.
8
+ */
9
+ const BINARY_VERSION = '1.0.0';
10
+ const APP_VERSION = '1.0.1';
11
+ let mockConfig;
12
+ jest.mock("../../utils/fsUtils.js", () => ({
13
+ findAndReadConfigFile: () => mockConfig,
14
+ }));
15
+ /**
16
+ * Puts one released version in the config's history and records every history it is
17
+ * handed back, so a case can read the entry as the consumer would store it.
18
+ */
19
+ function stageReleaseHistory() {
20
+ const releaseHistory = {
21
+ [APP_VERSION]: {
22
+ enabled: true,
23
+ mandatory: false,
24
+ downloadUrl: 'https://cdn.example.com/bundle',
25
+ packageHash: 'a3f1c0',
26
+ },
27
+ };
28
+ const saved = [];
29
+ mockConfig = {
30
+ bundleUploader: async () => ({ downloadUrl: 'https://cdn.example.com/bundle' }),
31
+ getReleaseHistory: async () => releaseHistory,
32
+ // The command edits the history in place, so what it saved is copied out here.
33
+ setReleaseHistory: async (_binaryVersion, _jsonFilePath, releaseInfo) => {
34
+ saved.push(structuredClone(releaseInfo));
35
+ },
36
+ };
37
+ return saved;
38
+ }
39
+ /**
40
+ * Parses an `update-history` invocation against the real command definition. Commander is
41
+ * asked to throw instead of exiting, and to keep its diagnostics to itself, so a rejected
42
+ * option can be asserted on without ending the worker or the output.
43
+ */
44
+ async function parseUpdateHistoryCommand(args) {
45
+ const { program } = await import("commander");
46
+ await import("./index.js");
47
+ const updateHistoryCommand = program.commands.find((command) => command.name() === 'update-history');
48
+ updateHistoryCommand?.exitOverride();
49
+ updateHistoryCommand?.configureOutput({ writeErr: () => { } });
50
+ await program.parseAsync(['update-history', ...args], { from: 'user' });
51
+ }
52
+ async function runUpdateHistoryCommand(args) {
53
+ await parseUpdateHistoryCommand(['-b', BINARY_VERSION, '-v', APP_VERSION, ...args]);
54
+ }
55
+ let saved;
56
+ beforeEach(() => {
57
+ jest.resetModules();
58
+ saved = stageReleaseHistory();
59
+ jest.spyOn(console, 'log').mockImplementation(() => { });
60
+ jest.spyOn(console, 'error').mockImplementation(() => { });
61
+ jest.spyOn(process, 'exit').mockImplementation(((code) => {
62
+ throw new Error(`process.exit(${code})`);
63
+ }));
64
+ });
65
+ afterEach(() => {
66
+ jest.restoreAllMocks();
67
+ // The command writes its JSON under the directory it was invoked in.
68
+ fs.rmSync(path.resolve(process.cwd(), "codepush-release-history"), { recursive: true, force: true });
69
+ });
70
+ describe("update-history command options", () => {
71
+ it("lowers the background wait of a release that is already out to zero seconds", async () => {
72
+ await runUpdateHistoryCommand(['--minimum-background-duration', '0']);
73
+ expect(saved).toHaveLength(1);
74
+ expect(saved[0][APP_VERSION].minimumBackgroundDuration).toBe(0);
75
+ });
76
+ it("leaves the entry saying nothing about the background wait when only --enable is given", async () => {
77
+ await runUpdateHistoryCommand(['--enable', 'false']);
78
+ expect(saved[0][APP_VERSION].enabled).toBe(false);
79
+ expect(saved[0][APP_VERSION]).not.toHaveProperty('minimumBackgroundDuration');
80
+ });
81
+ it("saves the rollout percentage when --rollout is the only option given", async () => {
82
+ await runUpdateHistoryCommand(['--rollout', '50']);
83
+ expect(saved[0][APP_VERSION].rollout).toBe(50);
84
+ });
85
+ it("exits without saving anything when no option says what to change", async () => {
86
+ await expect(runUpdateHistoryCommand([])).rejects.toThrow('process.exit(1)');
87
+ expect(saved).toHaveLength(0);
88
+ });
89
+ it("rejects a negative background wait and saves nothing", async () => {
90
+ await expect(runUpdateHistoryCommand(['--minimum-background-duration', '-1']))
91
+ .rejects.toThrow('process.exit(1)');
92
+ expect(saved).toHaveLength(0);
93
+ });
94
+ it.each([
95
+ ['a percentage above 100', '150'],
96
+ ['a percentage that is not a number', 'abc'],
97
+ ])("rejects %s and saves nothing", async (_scenario, rollout) => {
98
+ await expect(runUpdateHistoryCommand(['--rollout', rollout]))
99
+ .rejects.toThrow('process.exit(1)');
100
+ expect(saved).toHaveLength(0);
101
+ });
102
+ });
@@ -1,5 +1,5 @@
1
1
  import { stageReleaseHistoryFile } from "../../functions/stageReleaseHistoryFile.js";
2
- export async function updateReleaseHistory(appVersion, binaryVersion, getReleaseHistory, setReleaseHistory, platform, identifier, mandatory, enable, rollout) {
2
+ export async function updateReleaseHistory(appVersion, binaryVersion, getReleaseHistory, setReleaseHistory, platform, identifier, mandatory, enable, rollout, minimumBackgroundDuration) {
3
3
  const releaseHistory = await getReleaseHistory(binaryVersion, platform, identifier);
4
4
  const updateInfo = releaseHistory[appVersion];
5
5
  if (!updateInfo)
@@ -10,6 +10,9 @@ export async function updateReleaseHistory(appVersion, binaryVersion, getRelease
10
10
  updateInfo.enabled = enable;
11
11
  if (typeof rollout === "number")
12
12
  updateInfo.rollout = rollout;
13
+ // 0 is what lowers the wait to "apply on the next resume", so truthiness cannot decide here.
14
+ if (typeof minimumBackgroundDuration === "number")
15
+ updateInfo.minimumBackgroundDuration = minimumBackgroundDuration;
13
16
  try {
14
17
  await stageReleaseHistoryFile(binaryVersion, releaseHistory, platform, (jsonFilePath) => setReleaseHistory(binaryVersion, jsonFilePath, releaseHistory, platform, identifier));
15
18
  }
@@ -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",
3
+ "version": "13.3.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
@@ -200,6 +200,11 @@ async function checkForUpdate(handleBinaryVersionMismatchCallback = null) {
200
200
  * back on when the diff fails on its asset side.
201
201
  */
202
202
  asset_diff_download_url: diffPackageDownloadUrl,
203
+ /**
204
+ * Only present when the release asked for a background duration of its own, which
205
+ * then wins over the one the `sync` call passes.
206
+ */
207
+ minimum_background_duration: latestReleaseInfo.minimumBackgroundDuration,
203
208
  // (`enabled` will always be true in the release information obtained from the previous process.)
204
209
  is_available: latestReleaseInfo.enabled,
205
210
  package_hash: latestReleaseInfo.packageHash,
@@ -309,6 +314,11 @@ function mapToRemotePackageMetadata(updateInfo) {
309
314
  ...(updateInfo.asset_diff_download_url
310
315
  ? { assetDiffDownloadUrl: updateInfo.asset_diff_download_url }
311
316
  : {}),
317
+ // Presence is read off the type, not off truthiness, so that a release asking for `0`
318
+ // seconds keeps its own value instead of looking like a release that asked for nothing.
319
+ ...(typeof updateInfo.minimum_background_duration === 'number'
320
+ ? { minimumBackgroundDuration: updateInfo.minimum_background_duration }
321
+ : {}),
312
322
  };
313
323
  }
314
324
 
@@ -552,6 +562,7 @@ const sync = (() => {
552
562
  */
553
563
  async function syncInternal(options = {}, syncStatusChangeCallback, downloadProgressCallback, handleBinaryVersionMismatchCallback) {
554
564
  let resolvedInstallMode;
565
+ let resolvedMinimumBackgroundDuration;
555
566
  const syncOptions = {
556
567
  deploymentKey: null,
557
568
  ignoreFailedUpdates: true,
@@ -596,8 +607,8 @@ async function syncInternal(options = {}, syncStatusChangeCallback, downloadProg
596
607
  if (resolvedInstallMode == CodePush.InstallMode.ON_NEXT_RESTART) {
597
608
  log("Update is installed and will be run on the next app restart.");
598
609
  } else if (resolvedInstallMode == CodePush.InstallMode.ON_NEXT_RESUME) {
599
- if (syncOptions.minimumBackgroundDuration > 0) {
600
- log(`Update is installed and will be run after the app has been in the background for at least ${syncOptions.minimumBackgroundDuration} seconds.`);
610
+ if (resolvedMinimumBackgroundDuration > 0) {
611
+ log(`Update is installed and will be run after the app has been in the background for at least ${resolvedMinimumBackgroundDuration} seconds.`);
601
612
  } else {
602
613
  log("Update is installed and will be run when the app next resumes.");
603
614
  }
@@ -630,9 +641,11 @@ async function syncInternal(options = {}, syncStatusChangeCallback, downloadProg
630
641
 
631
642
  // Determine the correct install mode based on whether the update is mandatory or not.
632
643
  resolvedInstallMode = localPackage.isMandatory ? syncOptions.mandatoryInstallMode : syncOptions.installMode;
644
+ // `??` rather than `||`, so a release asking for `0` seconds keeps its own value.
645
+ resolvedMinimumBackgroundDuration = remotePackage.minimumBackgroundDuration ?? syncOptions.minimumBackgroundDuration;
633
646
 
634
647
  syncStatusChangeCallback(CodePush.SyncStatus.INSTALLING_UPDATE);
635
- await localPackage.install(resolvedInstallMode, syncOptions.minimumBackgroundDuration, () => {
648
+ await localPackage.install(resolvedInstallMode, resolvedMinimumBackgroundDuration, () => {
636
649
  syncStatusChangeCallback(CodePush.SyncStatus.UPDATE_INSTALLED);
637
650
  });
638
651
 
@@ -649,3 +649,60 @@ describe('telemetry callback errors', () => {
649
649
  expect(nativeBridge.saveStatusReportForRetry).not.toHaveBeenCalled();
650
650
  });
651
651
  });
652
+
653
+ /**
654
+ * A release can ask for its own minimum background duration - how long the app has to have
655
+ * been in the background before an update installed with `ON_NEXT_RESUME` is applied. The
656
+ * app passes a duration to `sync` for the releases that ask for nothing, and whichever of
657
+ * the two wins is what the native module is given when the update is installed.
658
+ */
659
+ describe('the minimum background duration an update is installed with', () => {
660
+ /** The release the CLI writes when the release carries its own background duration. */
661
+ function releaseAskingFor(minimumBackgroundDuration) {
662
+ const history = fullOnlyRelease();
663
+ history[LABEL].minimumBackgroundDuration = minimumBackgroundDuration;
664
+ return history;
665
+ }
666
+
667
+ /** The background duration the native module is given, in seconds. */
668
+ function installedMinimumBackgroundDuration(nativeBridge) {
669
+ expect(nativeBridge.installUpdate).toHaveBeenCalledTimes(1);
670
+ return nativeBridge.installUpdate.mock.calls[0][2];
671
+ }
672
+
673
+ it('takes the one the release history entry asks for over the one the sync call passes', async () => {
674
+ const { CodePush, nativeBridge } = loadCodePush({ releaseHistory: releaseAskingFor(600) });
675
+
676
+ const syncStatus = await CodePush.sync({
677
+ installMode: InstallMode.ON_NEXT_RESUME,
678
+ minimumBackgroundDuration: 3600,
679
+ });
680
+
681
+ expect(syncStatus).toBe(CodePush.SyncStatus.UPDATE_INSTALLED);
682
+ expect(installedMinimumBackgroundDuration(nativeBridge)).toBe(600);
683
+ });
684
+
685
+ it('keeps the one the sync call passes when the release history entry asks for none', async () => {
686
+ const { CodePush, nativeBridge } = loadCodePush({ releaseHistory: fullOnlyRelease() });
687
+
688
+ const syncStatus = await CodePush.sync({
689
+ installMode: InstallMode.ON_NEXT_RESUME,
690
+ minimumBackgroundDuration: 3600,
691
+ });
692
+
693
+ expect(syncStatus).toBe(CodePush.SyncStatus.UPDATE_INSTALLED);
694
+ expect(installedMinimumBackgroundDuration(nativeBridge)).toBe(3600);
695
+ });
696
+
697
+ it('runs a release that asks for zero seconds on the next resume, however long the sync call would have waited', async () => {
698
+ const { CodePush, nativeBridge } = loadCodePush({ releaseHistory: releaseAskingFor(0) });
699
+
700
+ const syncStatus = await CodePush.sync({
701
+ installMode: InstallMode.ON_NEXT_RESUME,
702
+ minimumBackgroundDuration: 3600,
703
+ });
704
+
705
+ expect(syncStatus).toBe(CodePush.SyncStatus.UPDATE_INSTALLED);
706
+ expect(installedMinimumBackgroundDuration(nativeBridge)).toBe(0);
707
+ });
708
+ });