Skip to content

Commit 4f58d87

Browse files
authored
feat(Runtime): Allow rollout cancellation and simplify decision logic (#83)
* refactor: tidy up code formatting * refactor: add JSDoc comments for type def * refactor: replace console.log with log function for consistency * docs: add clarification comment regarding clientUniqueId behavior on app re-installation * feat(Runtime): enhance rollout feature - Integrate the rollout check into the existing version check logic to improve cohesion and allow rollout cancellation - Prevent potential issues where a pending update might not proceed due to the latest update being a rollout target (scenario expected to be rare) * refactor: simplify rollout decision logic - The original logic cached decisions to reduce recomputation cost and ensure consistent results when the app version and rollout percentage were the same - Rollout determination uses a hashed value of the client ID, which CodePush stores using SharedPreferences or UserDefaults - The client ID can change if the app is uninstalled or its storage is cleared; the cache is also removed in such cases, so the lifecycle of the client ID and the cache are effectively the same - Therefore, even without caching, determining rollout status on every update check can still guarantee the same results (with the aforementioned constraints) - The hashing logic also appears lightweight enough to avoid any performance issues
1 parent c5ecdfe commit 4f58d87

File tree

11 files changed

+181
-166
lines changed

11 files changed

+181
-166
lines changed

CodePush.js

Lines changed: 81 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@ import { SemverVersioning } from './versioning/SemverVersioning'
66

77
let NativeCodePush = require("react-native").NativeModules.CodePush;
88
const PackageMixins = require("./package-mixins")(NativeCodePush);
9-
const RolloutStorage = require("react-native").NativeModules.RolloutStorage;
109

11-
const DEPLOYMENT_KEY = 'deprecated_deployment_key',
12-
ROLLOUT_CACHE_PREFIX = 'CodePushRolloutDecision_',
13-
ROLLOUT_CACHE_KEY = 'CodePushRolloutKey';
10+
const DEPLOYMENT_KEY = 'deprecated_deployment_key';
1411

12+
/**
13+
* @param deviceId {string}
14+
* @returns {number}
15+
*/
1516
function hashDeviceId(deviceId) {
1617
let hash = 0;
1718
for (let i = 0; i < deviceId.length; i++) {
@@ -21,43 +22,36 @@ function hashDeviceId(deviceId) {
2122
return Math.abs(hash);
2223
}
2324

24-
function getRolloutKey(label, rollout) {
25-
return `${ROLLOUT_CACHE_PREFIX}${label}_rollout_${rollout ?? 100}`;
26-
}
27-
25+
/**
26+
* @param clientId {string}
27+
* @param packageHash {string}
28+
* @returns {number}
29+
*/
2830
function getBucket(clientId, packageHash) {
2931
const hash = hashDeviceId(`${clientId ?? ''}_${packageHash ?? ''}`);
3032
return (Math.abs(hash) % 100);
3133
}
3234

33-
export async function shouldApplyCodePushUpdate(remotePackage, clientId, onRolloutSkipped) {
34-
if (remotePackage.rollout === undefined || remotePackage.rollout >= 100) {
35-
return true;
36-
}
37-
38-
const rolloutKey = getRolloutKey(remotePackage.label, remotePackage.rollout),
39-
cachedDecision = await RolloutStorage.getItem(rolloutKey);
35+
/**
36+
* Note that the `clientUniqueId` value may not guarantee the same value if the app is deleted and re-installed.
37+
* In other words, if a user re-installs the app, the result of this function may change.
38+
* @returns {Promise<boolean>}
39+
*/
40+
async function decideLatestReleaseIsInRollout(versioning, clientId, onRolloutSkipped) {
41+
const [latestVersion, latestReleaseInfo] = versioning.findLatestRelease();
4042

41-
if (cachedDecision != null) {
42-
// should apply if cachedDecision is true
43-
return cachedDecision === 'true';
43+
if (latestReleaseInfo.rollout === undefined || latestReleaseInfo.rollout >= 100) {
44+
return true;
4445
}
4546

46-
const bucket = getBucket(clientId, remotePackage.packageHash),
47-
inRollout = bucket < remotePackage.rollout,
48-
prevRolloutCacheKey = await RolloutStorage.getItem(ROLLOUT_CACHE_KEY);
49-
50-
console.log(`[CodePush] Bucket: ${bucket}, rollout: ${remotePackage.rollout}${inRollout ? 'IN' : 'OUT'}`);
47+
const bucket = getBucket(clientId, latestReleaseInfo.packageHash);
48+
const inRollout = bucket < latestReleaseInfo.rollout;
5149

52-
if(prevRolloutCacheKey)
53-
await RolloutStorage.removeItem(prevRolloutCacheKey);
54-
55-
await RolloutStorage.setItem(ROLLOUT_CACHE_KEY, rolloutKey);
56-
await RolloutStorage.setItem(rolloutKey, inRollout.toString());
50+
log(`Bucket: ${bucket}, rollout: ${latestReleaseInfo.rollout}${inRollout ? 'IN' : 'OUT'}`);
5751

5852
if (!inRollout) {
59-
console.log(`[CodePush] Skipping update due to rollout. Bucket ${bucket} >= rollout ${remotePackage.rollout}`);
60-
onRolloutSkipped?.(remotePackage.label);
53+
log(`Skipping update due to rollout. Bucket ${bucket} is not smaller than rollout range ${latestReleaseInfo.rollout}.`);
54+
onRolloutSkipped?.(latestVersion);
6155
}
6256

6357
return inRollout;
@@ -96,6 +90,9 @@ async function checkForUpdate(handleBinaryVersionMismatchCallback = null) {
9690
}
9791
}
9892

93+
/**
94+
* @type {RemotePackage|null|undefined}
95+
*/
9996
const update = await (async () => {
10097
try {
10198
const updateRequest = {
@@ -112,8 +109,8 @@ async function checkForUpdate(handleBinaryVersionMismatchCallback = null) {
112109
*/
113110
const updateChecker = sharedCodePushOptions.updateChecker;
114111
if (updateChecker) {
112+
// We do not provide rollout functionality. This could be implemented in the `updateChecker`.
115113
const { update_info } = await updateChecker(updateRequest);
116-
117114
return mapToRemotePackageMetadata(update_info);
118115
} else {
119116
/**
@@ -131,6 +128,9 @@ async function checkForUpdate(handleBinaryVersionMismatchCallback = null) {
131128

132129
const versioning = new SemverVersioning(releaseHistory);
133130

131+
const isInRollout = await decideLatestReleaseIsInRollout(versioning, nativeConfig.clientUniqueId, sharedCodePushOptions?.onRolloutSkipped);
132+
versioning.setIsLatestReleaseInRollout(isInRollout);
133+
134134
const shouldRollbackToBinary = versioning.shouldRollbackToBinary(runtimeVersion)
135135
if (shouldRollbackToBinary) {
136136
// Reset to latest major version and restart
@@ -175,7 +175,6 @@ async function checkForUpdate(handleBinaryVersionMismatchCallback = null) {
175175
package_size: 0,
176176
// not used at runtime.
177177
should_run_binary_version: false,
178-
rollout: latestReleaseInfo.rollout
179178
};
180179

181180
return mapToRemotePackageMetadata(updateInfo);
@@ -219,13 +218,6 @@ async function checkForUpdate(handleBinaryVersionMismatchCallback = null) {
219218
return null;
220219
} else {
221220
const remotePackage = { ...update, ...PackageMixins.remote() };
222-
223-
// Rollout filtering
224-
const shouldApply = await shouldApplyCodePushUpdate(remotePackage, nativeConfig.clientUniqueId, sharedCodePushOptions?.onRolloutSkipped);
225-
226-
if(!shouldApply)
227-
return { skipRollout: true };
228-
229221
remotePackage.failedInstall = await NativeCodePush.isFailedUpdate(remotePackage.packageHash);
230222
return remotePackage;
231223
}
@@ -255,7 +247,6 @@ function mapToRemotePackageMetadata(updateInfo) {
255247
packageHash: updateInfo.package_hash ?? '',
256248
packageSize: updateInfo.package_size ?? 0,
257249
downloadUrl: updateInfo.download_url ?? '',
258-
rollout: updateInfo.rollout ?? 100,
259250
};
260251
}
261252

@@ -280,7 +271,7 @@ async function getCurrentPackage() {
280271
async function getUpdateMetadata(updateState) {
281272
let updateMetadata = await NativeCodePush.getUpdateMetadata(updateState || CodePush.UpdateState.RUNNING);
282273
if (updateMetadata) {
283-
updateMetadata = {...PackageMixins.local, ...updateMetadata};
274+
updateMetadata = { ...PackageMixins.local, ...updateMetadata };
284275
updateMetadata.failedInstall = await NativeCodePush.isFailedUpdate(updateMetadata.packageHash);
285276
updateMetadata.isFirstRun = await NativeCodePush.isFirstRun(updateMetadata.packageHash);
286277
}
@@ -487,47 +478,47 @@ async function syncInternal(options = {}, syncStatusChangeCallback, downloadProg
487478
mandatoryInstallMode: CodePush.InstallMode.IMMEDIATE,
488479
minimumBackgroundDuration: 0,
489480
updateDialog: null,
490-
...options
481+
...options,
491482
};
492483

493484
syncStatusChangeCallback = typeof syncStatusChangeCallback === "function"
494485
? syncStatusChangeCallback
495486
: (syncStatus) => {
496-
switch(syncStatus) {
497-
case CodePush.SyncStatus.CHECKING_FOR_UPDATE:
498-
log("Checking for update.");
499-
break;
500-
case CodePush.SyncStatus.AWAITING_USER_ACTION:
501-
log("Awaiting user action.");
502-
break;
503-
case CodePush.SyncStatus.DOWNLOADING_PACKAGE:
504-
log("Downloading package.");
505-
break;
506-
case CodePush.SyncStatus.INSTALLING_UPDATE:
507-
log("Installing update.");
508-
break;
509-
case CodePush.SyncStatus.UP_TO_DATE:
510-
log("App is up to date.");
511-
break;
512-
case CodePush.SyncStatus.UPDATE_IGNORED:
513-
log("User cancelled the update.");
514-
break;
515-
case CodePush.SyncStatus.UPDATE_INSTALLED:
516-
if (resolvedInstallMode == CodePush.InstallMode.ON_NEXT_RESTART) {
517-
log("Update is installed and will be run on the next app restart.");
518-
} else if (resolvedInstallMode == CodePush.InstallMode.ON_NEXT_RESUME) {
519-
if (syncOptions.minimumBackgroundDuration > 0) {
520-
log(`Update is installed and will be run after the app has been in the background for at least ${syncOptions.minimumBackgroundDuration} seconds.`);
521-
} else {
522-
log("Update is installed and will be run when the app next resumes.");
523-
}
487+
switch (syncStatus) {
488+
case CodePush.SyncStatus.CHECKING_FOR_UPDATE:
489+
log("Checking for update.");
490+
break;
491+
case CodePush.SyncStatus.AWAITING_USER_ACTION:
492+
log("Awaiting user action.");
493+
break;
494+
case CodePush.SyncStatus.DOWNLOADING_PACKAGE:
495+
log("Downloading package.");
496+
break;
497+
case CodePush.SyncStatus.INSTALLING_UPDATE:
498+
log("Installing update.");
499+
break;
500+
case CodePush.SyncStatus.UP_TO_DATE:
501+
log("App is up to date.");
502+
break;
503+
case CodePush.SyncStatus.UPDATE_IGNORED:
504+
log("User cancelled the update.");
505+
break;
506+
case CodePush.SyncStatus.UPDATE_INSTALLED:
507+
if (resolvedInstallMode == CodePush.InstallMode.ON_NEXT_RESTART) {
508+
log("Update is installed and will be run on the next app restart.");
509+
} else if (resolvedInstallMode == CodePush.InstallMode.ON_NEXT_RESUME) {
510+
if (syncOptions.minimumBackgroundDuration > 0) {
511+
log(`Update is installed and will be run after the app has been in the background for at least ${syncOptions.minimumBackgroundDuration} seconds.`);
512+
} else {
513+
log("Update is installed and will be run when the app next resumes.");
524514
}
525-
break;
526-
case CodePush.SyncStatus.UNKNOWN_ERROR:
527-
log("An unknown error occurred.");
528-
break;
529-
}
530-
};
515+
}
516+
break;
517+
case CodePush.SyncStatus.UNKNOWN_ERROR:
518+
log("An unknown error occurred.");
519+
break;
520+
}
521+
};
531522

532523
let remotePackageLabel;
533524
try {
@@ -556,16 +547,11 @@ async function syncInternal(options = {}, syncStatusChangeCallback, downloadProg
556547
return CodePush.SyncStatus.UPDATE_INSTALLED;
557548
};
558549

559-
if(remotePackage?.skipRollout){
560-
syncStatusChangeCallback(CodePush.SyncStatus.UP_TO_DATE);
561-
return CodePush.SyncStatus.UP_TO_DATE;
562-
}
563-
564550
const updateShouldBeIgnored = await shouldUpdateBeIgnored(remotePackage, syncOptions);
565551

566552
if (!remotePackage || updateShouldBeIgnored) {
567553
if (updateShouldBeIgnored) {
568-
log("An update is available, but it is being ignored due to having been previously rolled back.");
554+
log("An update is available, but it is being ignored due to having been previously rolled back.");
569555
}
570556

571557
const currentPackage = await CodePush.getCurrentPackage();
@@ -604,18 +590,18 @@ async function syncInternal(options = {}, syncStatusChangeCallback, downloadProg
604590
onPress: () => {
605591
syncStatusChangeCallback(CodePush.SyncStatus.UPDATE_IGNORED);
606592
resolve(CodePush.SyncStatus.UPDATE_IGNORED);
607-
}
593+
},
608594
});
609595
}
610596

611597
// Since the install button should be placed to the
612598
// right of any other button, add it last
613599
dialogButtons.push({
614600
text: installButtonText,
615-
onPress:() => {
601+
onPress: () => {
616602
doDownloadAndInstall()
617603
.then(resolve, reject);
618-
}
604+
},
619605
})
620606

621607
// If the update has a description, and the developer
@@ -677,7 +663,7 @@ let CodePush;
677663
*
678664
* onSyncError: (label: string, error: Error) => void | undefined,
679665
* setOnSyncError(onSyncErrorFunction: (label: string, error: Error) => void | undefined): void,
680-
*
666+
*
681667
* onRolloutSkipped: (label: string, error: Error) => void | undefined,
682668
* setOnRolloutSkipped(onRolloutSkippedFunction: (label: string, error: Error) => void | undefined): void,
683669
* }}
@@ -729,7 +715,7 @@ const sharedCodePushOptions = {
729715
if (!onRolloutSkippedFunction) return;
730716
if (typeof onRolloutSkippedFunction !== 'function') throw new Error('Please pass a function to onRolloutSkipped');
731717
this.onRolloutSkipped = onRolloutSkippedFunction;
732-
}
718+
},
733719
}
734720

735721
function codePushify(options = {}) {
@@ -748,7 +734,7 @@ function codePushify(options = {}) {
748734
throw new Error(
749735
`Unable to find the "Component" class, please either:
750736
1. Upgrade to a newer version of React Native that supports it, or
751-
2. Call the codePush.sync API in your component instead of using the @codePush decorator`
737+
2. Call the codePush.sync API in your component instead of using the @codePush decorator`,
752738
);
753739
}
754740

@@ -808,7 +794,7 @@ function codePushify(options = {}) {
808794
}
809795

810796
render() {
811-
const props = {...this.props};
797+
const props = { ...this.props };
812798

813799
// We can set ref property on class components only (not stateless)
814800
// Check it by render method
@@ -855,7 +841,7 @@ if (NativeCodePush) {
855841
IMMEDIATE: NativeCodePush.codePushInstallModeImmediate, // Restart the app immediately
856842
ON_NEXT_RESTART: NativeCodePush.codePushInstallModeOnNextRestart, // Don't artificially restart the app. Allow the update to be "picked up" on the next app restart
857843
ON_NEXT_RESUME: NativeCodePush.codePushInstallModeOnNextResume, // Restart the app the next time it is resumed from the background
858-
ON_NEXT_SUSPEND: NativeCodePush.codePushInstallModeOnNextSuspend // Restart the app _while_ it is in the background,
844+
ON_NEXT_SUSPEND: NativeCodePush.codePushInstallModeOnNextSuspend, // Restart the app _while_ it is in the background,
859845
// but only after it has been in the background for "minimumBackgroundDuration" seconds (0 by default),
860846
// so that user context isn't lost unless the app suspension is long enough to not matter
861847
},
@@ -868,17 +854,17 @@ if (NativeCodePush) {
868854
CHECKING_FOR_UPDATE: 5,
869855
AWAITING_USER_ACTION: 6,
870856
DOWNLOADING_PACKAGE: 7,
871-
INSTALLING_UPDATE: 8
857+
INSTALLING_UPDATE: 8,
872858
},
873859
CheckFrequency: {
874860
ON_APP_START: 0,
875861
ON_APP_RESUME: 1,
876-
MANUAL: 2
862+
MANUAL: 2,
877863
},
878864
UpdateState: {
879865
RUNNING: NativeCodePush.codePushUpdateStateRunning,
880866
PENDING: NativeCodePush.codePushUpdateStatePending,
881-
LATEST: NativeCodePush.codePushUpdateStateLatest
867+
LATEST: NativeCodePush.codePushUpdateStateLatest,
882868
},
883869
DeploymentStatus: {
884870
FAILED: "DeploymentFailed",
@@ -892,11 +878,11 @@ if (NativeCodePush) {
892878
optionalIgnoreButtonLabel: "Ignore",
893879
optionalInstallButtonLabel: "Install",
894880
optionalUpdateMessage: "An update is available. Would you like to install it?",
895-
title: "Update available"
881+
title: "Update available",
896882
},
897883
DEFAULT_ROLLBACK_RETRY_OPTIONS: {
898884
delayInHours: 24,
899-
maxRetryAttempts: 1
885+
maxRetryAttempts: 1,
900886
},
901887
});
902888
} else {

android/app/src/main/java/com/microsoft/codepush/react/CodePush.java

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
import com.facebook.react.bridge.JavaScriptModule;
1111
import com.facebook.react.bridge.NativeModule;
1212
import com.facebook.react.bridge.ReactApplicationContext;
13-
import com.microsoft.codepush.react.RolloutStorageModule;
1413
import com.facebook.react.devsupport.interfaces.DevSupportManager;
1514
import com.facebook.react.modules.debug.interfaces.DeveloperSettings;
1615
import com.facebook.react.uimanager.ViewManager;
@@ -405,12 +404,10 @@ static ReactInstanceManager getReactInstanceManager() {
405404
public List<NativeModule> createNativeModules(ReactApplicationContext reactApplicationContext) {
406405
CodePushNativeModule codePushModule = new CodePushNativeModule(reactApplicationContext, this, mUpdateManager, mTelemetryManager, mSettingsManager);
407406
CodePushDialog dialogModule = new CodePushDialog(reactApplicationContext);
408-
RolloutStorageModule rolloutStorageModule = new RolloutStorageModule(reactApplicationContext);
409407

410408
List<NativeModule> nativeModules = new ArrayList<>();
411409
nativeModules.add(codePushModule);
412410
nativeModules.add(dialogModule);
413-
nativeModules.add(rolloutStorageModule);
414411
return nativeModules;
415412
}
416413

0 commit comments

Comments
 (0)