Skip to content

feat(Runtime): Allow rollout cancellation and simplify decision logic #83

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Aug 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 81 additions & 95 deletions CodePush.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ import { SemverVersioning } from './versioning/SemverVersioning'

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

const DEPLOYMENT_KEY = 'deprecated_deployment_key',
ROLLOUT_CACHE_PREFIX = 'CodePushRolloutDecision_',
ROLLOUT_CACHE_KEY = 'CodePushRolloutKey';
const DEPLOYMENT_KEY = 'deprecated_deployment_key';

/**
* @param deviceId {string}
* @returns {number}
*/
function hashDeviceId(deviceId) {
let hash = 0;
for (let i = 0; i < deviceId.length; i++) {
Expand All @@ -21,43 +22,36 @@ function hashDeviceId(deviceId) {
return Math.abs(hash);
}

function getRolloutKey(label, rollout) {
return `${ROLLOUT_CACHE_PREFIX}${label}_rollout_${rollout ?? 100}`;
}

/**
* @param clientId {string}
* @param packageHash {string}
* @returns {number}
*/
function getBucket(clientId, packageHash) {
const hash = hashDeviceId(`${clientId ?? ''}_${packageHash ?? ''}`);
return (Math.abs(hash) % 100);
}

export async function shouldApplyCodePushUpdate(remotePackage, clientId, onRolloutSkipped) {
if (remotePackage.rollout === undefined || remotePackage.rollout >= 100) {
return true;
}

const rolloutKey = getRolloutKey(remotePackage.label, remotePackage.rollout),
cachedDecision = await RolloutStorage.getItem(rolloutKey);
/**
* Note that the `clientUniqueId` value may not guarantee the same value if the app is deleted and re-installed.
* In other words, if a user re-installs the app, the result of this function may change.
* @returns {Promise<boolean>}
*/
async function decideLatestReleaseIsInRollout(versioning, clientId, onRolloutSkipped) {
const [latestVersion, latestReleaseInfo] = versioning.findLatestRelease();

if (cachedDecision != null) {
// should apply if cachedDecision is true
return cachedDecision === 'true';
if (latestReleaseInfo.rollout === undefined || latestReleaseInfo.rollout >= 100) {
return true;
}

const bucket = getBucket(clientId, remotePackage.packageHash),
inRollout = bucket < remotePackage.rollout,
prevRolloutCacheKey = await RolloutStorage.getItem(ROLLOUT_CACHE_KEY);

console.log(`[CodePush] Bucket: ${bucket}, rollout: ${remotePackage.rollout} → ${inRollout ? 'IN' : 'OUT'}`);
const bucket = getBucket(clientId, latestReleaseInfo.packageHash);
const inRollout = bucket < latestReleaseInfo.rollout;

if(prevRolloutCacheKey)
await RolloutStorage.removeItem(prevRolloutCacheKey);

await RolloutStorage.setItem(ROLLOUT_CACHE_KEY, rolloutKey);
await RolloutStorage.setItem(rolloutKey, inRollout.toString());
log(`Bucket: ${bucket}, rollout: ${latestReleaseInfo.rollout} → ${inRollout ? 'IN' : 'OUT'}`);

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

return inRollout;
Expand Down Expand Up @@ -96,6 +90,9 @@ async function checkForUpdate(handleBinaryVersionMismatchCallback = null) {
}
}

/**
* @type {RemotePackage|null|undefined}
*/
const update = await (async () => {
try {
const updateRequest = {
Expand All @@ -112,8 +109,8 @@ async function checkForUpdate(handleBinaryVersionMismatchCallback = null) {
*/
const updateChecker = sharedCodePushOptions.updateChecker;
if (updateChecker) {
// We do not provide rollout functionality. This could be implemented in the `updateChecker`.
const { update_info } = await updateChecker(updateRequest);

return mapToRemotePackageMetadata(update_info);
} else {
/**
Expand All @@ -131,6 +128,9 @@ async function checkForUpdate(handleBinaryVersionMismatchCallback = null) {

const versioning = new SemverVersioning(releaseHistory);

const isInRollout = await decideLatestReleaseIsInRollout(versioning, nativeConfig.clientUniqueId, sharedCodePushOptions?.onRolloutSkipped);
versioning.setIsLatestReleaseInRollout(isInRollout);

const shouldRollbackToBinary = versioning.shouldRollbackToBinary(runtimeVersion)
if (shouldRollbackToBinary) {
// Reset to latest major version and restart
Expand Down Expand Up @@ -175,7 +175,6 @@ async function checkForUpdate(handleBinaryVersionMismatchCallback = null) {
package_size: 0,
// not used at runtime.
should_run_binary_version: false,
rollout: latestReleaseInfo.rollout
};

return mapToRemotePackageMetadata(updateInfo);
Expand Down Expand Up @@ -219,13 +218,6 @@ async function checkForUpdate(handleBinaryVersionMismatchCallback = null) {
return null;
} else {
const remotePackage = { ...update, ...PackageMixins.remote() };

// Rollout filtering
const shouldApply = await shouldApplyCodePushUpdate(remotePackage, nativeConfig.clientUniqueId, sharedCodePushOptions?.onRolloutSkipped);

if(!shouldApply)
return { skipRollout: true };

remotePackage.failedInstall = await NativeCodePush.isFailedUpdate(remotePackage.packageHash);
return remotePackage;
}
Expand Down Expand Up @@ -255,7 +247,6 @@ function mapToRemotePackageMetadata(updateInfo) {
packageHash: updateInfo.package_hash ?? '',
packageSize: updateInfo.package_size ?? 0,
downloadUrl: updateInfo.download_url ?? '',
rollout: updateInfo.rollout ?? 100,
};
}

Expand All @@ -280,7 +271,7 @@ async function getCurrentPackage() {
async function getUpdateMetadata(updateState) {
let updateMetadata = await NativeCodePush.getUpdateMetadata(updateState || CodePush.UpdateState.RUNNING);
if (updateMetadata) {
updateMetadata = {...PackageMixins.local, ...updateMetadata};
updateMetadata = { ...PackageMixins.local, ...updateMetadata };
updateMetadata.failedInstall = await NativeCodePush.isFailedUpdate(updateMetadata.packageHash);
updateMetadata.isFirstRun = await NativeCodePush.isFirstRun(updateMetadata.packageHash);
}
Expand Down Expand Up @@ -487,47 +478,47 @@ async function syncInternal(options = {}, syncStatusChangeCallback, downloadProg
mandatoryInstallMode: CodePush.InstallMode.IMMEDIATE,
minimumBackgroundDuration: 0,
updateDialog: null,
...options
...options,
};

syncStatusChangeCallback = typeof syncStatusChangeCallback === "function"
? syncStatusChangeCallback
: (syncStatus) => {
switch(syncStatus) {
case CodePush.SyncStatus.CHECKING_FOR_UPDATE:
log("Checking for update.");
break;
case CodePush.SyncStatus.AWAITING_USER_ACTION:
log("Awaiting user action.");
break;
case CodePush.SyncStatus.DOWNLOADING_PACKAGE:
log("Downloading package.");
break;
case CodePush.SyncStatus.INSTALLING_UPDATE:
log("Installing update.");
break;
case CodePush.SyncStatus.UP_TO_DATE:
log("App is up to date.");
break;
case CodePush.SyncStatus.UPDATE_IGNORED:
log("User cancelled the update.");
break;
case CodePush.SyncStatus.UPDATE_INSTALLED:
if (resolvedInstallMode == CodePush.InstallMode.ON_NEXT_RESTART) {
log("Update is installed and will be run on the next app restart.");
} else if (resolvedInstallMode == CodePush.InstallMode.ON_NEXT_RESUME) {
if (syncOptions.minimumBackgroundDuration > 0) {
log(`Update is installed and will be run after the app has been in the background for at least ${syncOptions.minimumBackgroundDuration} seconds.`);
} else {
log("Update is installed and will be run when the app next resumes.");
}
switch (syncStatus) {
case CodePush.SyncStatus.CHECKING_FOR_UPDATE:
log("Checking for update.");
break;
case CodePush.SyncStatus.AWAITING_USER_ACTION:
log("Awaiting user action.");
break;
case CodePush.SyncStatus.DOWNLOADING_PACKAGE:
log("Downloading package.");
break;
case CodePush.SyncStatus.INSTALLING_UPDATE:
log("Installing update.");
break;
case CodePush.SyncStatus.UP_TO_DATE:
log("App is up to date.");
break;
case CodePush.SyncStatus.UPDATE_IGNORED:
log("User cancelled the update.");
break;
case CodePush.SyncStatus.UPDATE_INSTALLED:
if (resolvedInstallMode == CodePush.InstallMode.ON_NEXT_RESTART) {
log("Update is installed and will be run on the next app restart.");
} else if (resolvedInstallMode == CodePush.InstallMode.ON_NEXT_RESUME) {
if (syncOptions.minimumBackgroundDuration > 0) {
log(`Update is installed and will be run after the app has been in the background for at least ${syncOptions.minimumBackgroundDuration} seconds.`);
} else {
log("Update is installed and will be run when the app next resumes.");
}
break;
case CodePush.SyncStatus.UNKNOWN_ERROR:
log("An unknown error occurred.");
break;
}
};
}
break;
case CodePush.SyncStatus.UNKNOWN_ERROR:
log("An unknown error occurred.");
break;
}
};

let remotePackageLabel;
try {
Expand Down Expand Up @@ -556,16 +547,11 @@ async function syncInternal(options = {}, syncStatusChangeCallback, downloadProg
return CodePush.SyncStatus.UPDATE_INSTALLED;
};

if(remotePackage?.skipRollout){
syncStatusChangeCallback(CodePush.SyncStatus.UP_TO_DATE);
return CodePush.SyncStatus.UP_TO_DATE;
}

const updateShouldBeIgnored = await shouldUpdateBeIgnored(remotePackage, syncOptions);

if (!remotePackage || updateShouldBeIgnored) {
if (updateShouldBeIgnored) {
log("An update is available, but it is being ignored due to having been previously rolled back.");
log("An update is available, but it is being ignored due to having been previously rolled back.");
}

const currentPackage = await CodePush.getCurrentPackage();
Expand Down Expand Up @@ -604,18 +590,18 @@ async function syncInternal(options = {}, syncStatusChangeCallback, downloadProg
onPress: () => {
syncStatusChangeCallback(CodePush.SyncStatus.UPDATE_IGNORED);
resolve(CodePush.SyncStatus.UPDATE_IGNORED);
}
},
});
}

// Since the install button should be placed to the
// right of any other button, add it last
dialogButtons.push({
text: installButtonText,
onPress:() => {
onPress: () => {
doDownloadAndInstall()
.then(resolve, reject);
}
},
})

// If the update has a description, and the developer
Expand Down Expand Up @@ -677,7 +663,7 @@ let CodePush;
*
* onSyncError: (label: string, error: Error) => void | undefined,
* setOnSyncError(onSyncErrorFunction: (label: string, error: Error) => void | undefined): void,
*
*
* onRolloutSkipped: (label: string, error: Error) => void | undefined,
* setOnRolloutSkipped(onRolloutSkippedFunction: (label: string, error: Error) => void | undefined): void,
* }}
Expand Down Expand Up @@ -729,7 +715,7 @@ const sharedCodePushOptions = {
if (!onRolloutSkippedFunction) return;
if (typeof onRolloutSkippedFunction !== 'function') throw new Error('Please pass a function to onRolloutSkipped');
this.onRolloutSkipped = onRolloutSkippedFunction;
}
},
}

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

Expand Down Expand Up @@ -808,7 +794,7 @@ function codePushify(options = {}) {
}

render() {
const props = {...this.props};
const props = { ...this.props };

// We can set ref property on class components only (not stateless)
// Check it by render method
Expand Down Expand Up @@ -855,7 +841,7 @@ if (NativeCodePush) {
IMMEDIATE: NativeCodePush.codePushInstallModeImmediate, // Restart the app immediately
ON_NEXT_RESTART: NativeCodePush.codePushInstallModeOnNextRestart, // Don't artificially restart the app. Allow the update to be "picked up" on the next app restart
ON_NEXT_RESUME: NativeCodePush.codePushInstallModeOnNextResume, // Restart the app the next time it is resumed from the background
ON_NEXT_SUSPEND: NativeCodePush.codePushInstallModeOnNextSuspend // Restart the app _while_ it is in the background,
ON_NEXT_SUSPEND: NativeCodePush.codePushInstallModeOnNextSuspend, // Restart the app _while_ it is in the background,
// but only after it has been in the background for "minimumBackgroundDuration" seconds (0 by default),
// so that user context isn't lost unless the app suspension is long enough to not matter
},
Expand All @@ -868,17 +854,17 @@ if (NativeCodePush) {
CHECKING_FOR_UPDATE: 5,
AWAITING_USER_ACTION: 6,
DOWNLOADING_PACKAGE: 7,
INSTALLING_UPDATE: 8
INSTALLING_UPDATE: 8,
},
CheckFrequency: {
ON_APP_START: 0,
ON_APP_RESUME: 1,
MANUAL: 2
MANUAL: 2,
},
UpdateState: {
RUNNING: NativeCodePush.codePushUpdateStateRunning,
PENDING: NativeCodePush.codePushUpdateStatePending,
LATEST: NativeCodePush.codePushUpdateStateLatest
LATEST: NativeCodePush.codePushUpdateStateLatest,
},
DeploymentStatus: {
FAILED: "DeploymentFailed",
Expand All @@ -892,11 +878,11 @@ if (NativeCodePush) {
optionalIgnoreButtonLabel: "Ignore",
optionalInstallButtonLabel: "Install",
optionalUpdateMessage: "An update is available. Would you like to install it?",
title: "Update available"
title: "Update available",
},
DEFAULT_ROLLBACK_RETRY_OPTIONS: {
delayInHours: 24,
maxRetryAttempts: 1
maxRetryAttempts: 1,
},
});
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
import com.facebook.react.bridge.JavaScriptModule;
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.microsoft.codepush.react.RolloutStorageModule;
import com.facebook.react.devsupport.interfaces.DevSupportManager;
import com.facebook.react.modules.debug.interfaces.DeveloperSettings;
import com.facebook.react.uimanager.ViewManager;
Expand Down Expand Up @@ -405,12 +404,10 @@ static ReactInstanceManager getReactInstanceManager() {
public List<NativeModule> createNativeModules(ReactApplicationContext reactApplicationContext) {
CodePushNativeModule codePushModule = new CodePushNativeModule(reactApplicationContext, this, mUpdateManager, mTelemetryManager, mSettingsManager);
CodePushDialog dialogModule = new CodePushDialog(reactApplicationContext);
RolloutStorageModule rolloutStorageModule = new RolloutStorageModule(reactApplicationContext);

List<NativeModule> nativeModules = new ArrayList<>();
nativeModules.add(codePushModule);
nativeModules.add(dialogModule);
nativeModules.add(rolloutStorageModule);
return nativeModules;
}

Expand Down
Loading