Skip to content
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
38 changes: 38 additions & 0 deletions CodePush.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { AcquisitionManager as Sdk, DownloadStatus } from "./lib/commonjs/acquisition-sdk/acquisition-sdk";
import { createCodePushProvider } from "./lib/commonjs/react/code-push-provider";
import { useCodePush } from "./lib/commonjs/react/use-code-push";
import { createUpdateStore } from "./lib/commonjs/update-state/update-store";
import { onceUntilRejected } from "./lib/commonjs/once-until-rejected";
import { Alert } from "./AlertAdapter";
import requestFetchAdapter from "./request-fetch-adapter";
Expand Down Expand Up @@ -80,7 +82,32 @@ async function downloadUpdate(remotePackage, downloadProgressCallback, reportSta
}
}

// The state that useCodePush() returns. It lives at module level, outside React, because the legacy public functions
// (checkForUpdate(), sync(), the codePush() HOC) must keep updating it. sync() and the HOC check through
// checkForUpdate(), so reporting here covers every check.
const updateStore = createUpdateStore();

async function checkForUpdate(deploymentKey = null, handleBinaryVersionMismatchCallback = null) {
updateStore.dispatch({ type: "checkStarted" });
try {
// The legacy API reports a binary mismatch only through this callback (the check resolves to null), so it is
// wrapped to also capture it for the state.
let incompatibleUpdate;
const update = await queryUpdate(deploymentKey, (mismatch) => {
incompatibleUpdate = { targetBinaryVersion: mismatch.appVersion };
if (typeof handleBinaryVersionMismatchCallback === "function") {
handleBinaryVersionMismatchCallback(mismatch);
}
});
updateStore.dispatch({ type: "checkCompleted", update, incompatibleUpdate });
return update;
} catch (error) {
updateStore.dispatch({ type: "checkFailed", error });
throw error;
}
}

async function queryUpdate(deploymentKey, handleBinaryVersionMismatchCallback) {
/*
* Before we ask the server if an update exists, we
* need to retrieve three pieces of information from the
Expand Down Expand Up @@ -221,6 +248,7 @@ async function installUpdate(localPackage, installMode = NativeCodePush.codePush
log.info(`Installing update. ${logFields({ ...packageLogFields(localPackage), installMode: installModeName(installMode), minimumBackgroundDuration })}`);
await NativeCodePush.installUpdate(localPackageCopy, installMode, minimumBackgroundDuration);
log.info(`Installed update. ${installedUpdateNextStep(installMode, minimumBackgroundDuration)}`);
updateStore.dispatch({ type: "updateInstalled", packageHash: localPackage.packageHash });
updateInstalledCallback && updateInstalledCallback();
if (installMode == NativeCodePush.codePushInstallModeImmediate) {
NativeCodePush.restartApp(false);
Expand Down Expand Up @@ -797,5 +825,15 @@ if (NativeCodePush) {
notifyAppReady: () => CodePush.notifyAppReady(),
appState: AppState,
CheckFrequency: CodePush.CheckFrequency,
}, {
store: updateStore,
// Narrower than the legacy functions they wrap: the hook takes no positional callbacks, and reports through the
// state instead.
actions: {
checkForUpdate: (options = {}) => checkForUpdate(options.deploymentKey),
sync: (options) => sync(options),
restartApp,
},
});
module.exports.useCodePush = useCodePush;
}
55 changes: 54 additions & 1 deletion docs/api-js.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ After the app's first render, the Provider runs the launch step:
- With the default `checkFrequency`, it checks for an update, downloads and installs it according to the props. The check calls [`notifyAppReady`](#codepushnotifyappready) first.
- With `checkFrequency={codePush.CheckFrequency.MANUAL}`, it only calls [`notifyAppReady`](#codepushnotifyappready).

Either way, CodePush keeps an update that started fine. The Provider catches every failure of the work it starts and passes it to `onError`, so no promise rejection goes unhandled. When it unmounts, it removes its `AppState` listener.
Either way, CodePush keeps an update that started fine. The Provider catches every failure of the work it starts and passes it to `onError`, so no promise rejection goes unhandled. When it unmounts, it removes its `AppState` listener. Components inside it can read the update state with [`useCodePush`](#usecodepush).

*NOTE: Put the Provider at the app root, outside auth gates, navigation conditions, and screens that render late. The Provider calls `notifyAppReady` when it mounts. If it only mounts after login, and the user closes the app before logging in, CodePush rolls the new update back on the next start.*

Expand All @@ -63,6 +63,59 @@ Props:

If a prop changes after the first render, the next check on resume and the next `onError` call use the new value. The launch step does not run again.

#### useCodePush

```tsx
import { useCodePush } from "@bitrise/code-push-sdk";

const { isChecking, isUpdateAvailable, availableUpdate, incompatibleUpdate, checkError, checkForUpdate, sync, restartApp } = useCodePush();
```

Returns the update state, and re-renders the component when it changes. Call it in any component inside [`CodePushProvider`](#codepushprovider). It throws outside the Provider, because without the Provider nothing calls [`notifyAppReady`](#codepushnotifyappready), and every update rolls back.

The state shows every update check: the Provider's, and those of [`checkForUpdate`](#codepushcheckforupdate), [`sync`](#codepushsync) and the `codePush()` higher-order component. It starts empty each time the app starts.

* __isChecking__ *(Boolean)* - A check for an update runs.

* __isUpdateAvailable__ *(Boolean)* - The last check found an update that did not fail to install before, and that is not installed yet.

* __availableUpdate__ *([RemotePackage](#remotepackage))* - The update that the last check found, if `isUpdateAvailable`.

* __incompatibleUpdate__ *({ targetBinaryVersion?: String })* - A release that the last check found, but that this binary cannot install. For example, show a banner that asks the user to update the app from the store. `targetBinaryVersion` is the semver range the release targets (e.g. `"^2.0.0"`), when that range is why it is incompatible. Check `incompatibleUpdate` itself, not `targetBinaryVersion`, to decide whether to show the banner.

* __checkError__ *(Error)* - Why the last check failed. A new check clears it.

* __checkForUpdate__ *(options?: { deploymentKey?: String })* - Like [`codePush.checkForUpdate`](#codepushcheckforupdate).

* __sync__ *(options?: [SyncOptions](#codepushoptions))* - Like [`codePush.sync`](#codepushsync).

* __restartApp__ *(onlyIfUpdateIsPending?)* - The same as [`codePush.restartApp`](#codepushrestartapp).

The `sync` and `checkForUpdate` of the hook use the props of the Provider, such as `deploymentKey`, for every option that you do not pass. They take no callbacks. If you need a status or progress callback, call `codePush.sync` directly: its checks show in the state too.

Example: a "Check for updates" button on a settings screen.

```tsx
import codePush, { useCodePush } from "@bitrise/code-push-sdk";
import { Button, Text, View } from "react-native";

function CheckForUpdatesButton() {
const { isChecking, checkError, sync } = useCodePush();

return (
<View>
<Button
title={isChecking ? "Checking for updates..." : "Check for updates"}
disabled={isChecking}
// sync() logs its failures, and checkError shows a failed check.
onPress={() => sync({ updateDialog: true, installMode: codePush.InstallMode.IMMEDIATE }).catch(() => {})}
/>
{checkError && <Text>Could not check for updates. Try again later.</Text>}
</View>
);
}
```

#### codePush

You can also call [`sync`](#codepushsync) from a `useEffect` in your root component instead of using [`CodePushProvider`](#codepushprovider). Below are some examples of ways you can set this up (you can pick one or even use a combination):
Expand Down
23 changes: 18 additions & 5 deletions src/react/code-push-provider.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';

import type { CodePushProviderProps } from '../../typings/react-native-code-push';
import { AppLifecycleDeps, startAppLifecycle } from '../app-lifecycle/app-lifecycle';
import { CodePushContextValue, StoreContext } from './store-context';

export function createCodePushProvider(deps: AppLifecycleDeps) {
export function createCodePushProvider(deps: AppLifecycleDeps, { store, actions }: CodePushContextValue) {
function CodePushProvider({ children, checkFrequency, onError, ...syncOptions }: CodePushProviderProps) {
const latest = useRef({ checkFrequency, onError, syncOptions });

// Declared before the start effect, so it runs first. It runs after every render, so resume checks and onError
// read the latest props.
// Declared before the start effect, so it runs first. It runs after every render, so resume checks, onError and
// the hook's actions read the latest props.
useEffect(() => {
latest.current = { checkFrequency, onError, syncOptions };
});
Expand All @@ -17,7 +18,19 @@ export function createCodePushProvider(deps: AppLifecycleDeps) {
// Prop changes do not run the launch step again.
useEffect(() => startAppLifecycle(deps, () => latest.current), []);

return <>{children}</>;
// Created once, so state changes re-render only the components that call useCodePush(). The hook's calls default
// to the Provider's props.
const [contextValue] = useState<CodePushContextValue>(() => ({
store,
actions: {
...actions,
sync: (options) => actions.sync({ ...latest.current.syncOptions, ...options }),
checkForUpdate: (options) =>
actions.checkForUpdate({ deploymentKey: latest.current.syncOptions.deploymentKey, ...options }),
},
}));

return <StoreContext.Provider value={contextValue}>{children}</StoreContext.Provider>;
}

return CodePushProvider;
Expand Down
16 changes: 16 additions & 0 deletions src/react/store-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { createContext } from 'react';

import type { UseCodePushResult } from '../../typings/react-native-code-push';
import { UpdateStore } from '../update-state/update-store';

export type UpdateActions = Pick<UseCodePushResult, 'checkForUpdate' | 'sync' | 'restartApp'>;

// The actions travel through the context, not an import, because they are implemented in the root CodePush.js, which
// src/ cannot import.
export interface CodePushContextValue {
store: Pick<UpdateStore, 'getSnapshot' | 'subscribe'>;
actions: UpdateActions;
}

export const StoreContext = createContext<CodePushContextValue | null>(null);
StoreContext.displayName = 'CodePushContext';
16 changes: 16 additions & 0 deletions src/react/use-code-push.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { useContext, useMemo, useSyncExternalStore } from 'react';

import type { UseCodePushResult } from '../../typings/react-native-code-push';
import { StoreContext } from './store-context';

export function useCodePush(): UseCodePushResult {
const context = useContext(StoreContext);
// Without the Provider nothing calls notifyAppReady(), so every update would roll back.
if (!context) {
throw new Error('useCodePush must be used inside <CodePushProvider>.');
}
const { store, actions } = context;
const state = useSyncExternalStore(store.subscribe, store.getSnapshot);
// A new object only when the state changes, so it is safe in dependency arrays.
return useMemo(() => ({ ...state, ...actions }), [state, actions]);
}
150 changes: 150 additions & 0 deletions src/update-state/__tests__/update-state.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import * as assert from 'assert';

import type { RemotePackage } from '../../../typings/react-native-code-push';
import { initialState, reduce, StoreState, UpdateEvent } from '../update-state';

function reduceAll(events: UpdateEvent[], state: StoreState = initialState): StoreState {
return events.reduce(reduce, state);
}

const update = { label: 'v2', packageHash: 'hash', failedInstall: false } as RemotePackage;
const started: UpdateEvent = { type: 'checkStarted' };

describe('reduce', () => {
it('starts idle, with no update and no error', () => {
assert.deepStrictEqual(initialState, {
runningChecks: 0,
snapshot: { isChecking: false, isUpdateAvailable: false },
});
});

it('checkStarted sets isChecking', () => {
const { snapshot } = reduce(initialState, started);

assert.strictEqual(snapshot.isChecking, true);
assert.strictEqual(snapshot.isUpdateAvailable, false);
});

it('a check that returns an update makes it available', () => {
const { snapshot } = reduceAll([started, { type: 'checkCompleted', update }]);

assert.strictEqual(snapshot.isChecking, false);
assert.strictEqual(snapshot.isUpdateAvailable, true);
assert.strictEqual(snapshot.availableUpdate, update);
assert.strictEqual(snapshot.checkError, undefined);
});

it('a check that finds no update clears the update of the previous check', () => {
const { snapshot } = reduceAll([
started,
{ type: 'checkCompleted', update },
started,
{ type: 'checkCompleted', update: null },
]);

assert.strictEqual(snapshot.isChecking, false);
assert.strictEqual(snapshot.isUpdateAvailable, false);
assert.strictEqual(snapshot.availableUpdate, undefined);
});

it('an update that failed to install before is not available, as sync() ignores it', () => {
const { snapshot } = reduceAll([started, { type: 'checkCompleted', update: { ...update, failedInstall: true } }]);

assert.strictEqual(snapshot.isUpdateAvailable, false);
assert.strictEqual(snapshot.availableUpdate, undefined);
});

it('a check that finds an incompatible release sets incompatibleUpdate, and the next check clears it', () => {
const incompatibleUpdate = { targetBinaryVersion: '^2.0.0' };
const found = reduceAll([started, { type: 'checkCompleted', update: null, incompatibleUpdate }]);

assert.strictEqual(found.snapshot.incompatibleUpdate, incompatibleUpdate);
assert.strictEqual(found.snapshot.isUpdateAvailable, false);

const cleared = reduceAll([started, { type: 'checkCompleted', update: null }], found);
assert.strictEqual(cleared.snapshot.incompatibleUpdate, undefined);
});

it('a failed check keeps incompatibleUpdate', () => {
const incompatibleUpdate = { targetBinaryVersion: '^2.0.0' };
const { snapshot } = reduceAll([
started,
{ type: 'checkCompleted', update: null, incompatibleUpdate },
started,
{ type: 'checkFailed', error: new Error('network') },
]);

assert.strictEqual(snapshot.incompatibleUpdate, incompatibleUpdate);
});

it('keeps the update of the previous check while a new check runs', () => {
const { snapshot } = reduceAll([started, { type: 'checkCompleted', update }, started]);

assert.strictEqual(snapshot.isChecking, true);
assert.strictEqual(snapshot.isUpdateAvailable, true);
assert.strictEqual(snapshot.availableUpdate, update);
});

it('checkFailed clears isChecking and sets checkError', () => {
const error = new Error('network');
const { snapshot } = reduceAll([started, { type: 'checkFailed', error }]);

assert.strictEqual(snapshot.isChecking, false);
assert.strictEqual(snapshot.checkError, error);
});

it('a new check clears checkError', () => {
const failed = reduceAll([started, { type: 'checkFailed', error: new Error('network') }]);

assert.strictEqual(reduce(failed, started).snapshot.checkError, undefined);
assert.strictEqual(reduceAll([started, { type: 'checkCompleted', update }], failed).snapshot.checkError, undefined);
});

it('isChecking stays true until every overlapping check ends', () => {
const twoRunning = reduceAll([started, started]);
assert.strictEqual(twoRunning.runningChecks, 2);
assert.strictEqual(twoRunning.snapshot.isChecking, true);

const oneRunning = reduce(twoRunning, { type: 'checkCompleted', update });
assert.strictEqual(oneRunning.snapshot.isChecking, true);
assert.strictEqual(oneRunning.snapshot.availableUpdate, update, 'the result of the first check shows');

const error = new Error('network');
const noneRunning = reduce(oneRunning, { type: 'checkFailed', error });
assert.strictEqual(noneRunning.runningChecks, 0);
assert.strictEqual(noneRunning.snapshot.isChecking, false);
assert.strictEqual(noneRunning.snapshot.checkError, error);
});

it('a second overlapping check changes only the internal count, not the snapshot', () => {
const one = reduce(initialState, started);
const two = reduce(one, started);

assert.notStrictEqual(two, one);
assert.strictEqual(two.snapshot, one.snapshot);
});

it('installing the available update clears it', () => {
const { snapshot } = reduceAll([
started,
{ type: 'checkCompleted', update },
{ type: 'updateInstalled', packageHash: 'hash' },
]);

assert.strictEqual(snapshot.isUpdateAvailable, false);
assert.strictEqual(snapshot.availableUpdate, undefined);
});

it('installing another update keeps the available one', () => {
const found = reduceAll([started, { type: 'checkCompleted', update }]);

assert.strictEqual(reduce(found, { type: 'updateInstalled', packageHash: 'other' }), found);
});

it('does not change the state it is given', () => {
const before = JSON.parse(JSON.stringify(initialState));
reduceAll([started, { type: 'checkCompleted', update }]);

assert.deepStrictEqual(initialState, before);
});
});
Loading
Loading