From b6c7641c5ebd8feaf4da9d3b121f2c0785a9f925 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliv=C3=A9r=20Falvai?= Date: Tue, 10 Nov 2026 11:17:19 +0100 Subject: [PATCH] Add download, install and pending-restart state to useCodePush() Co-Authored-By: Claude Opus 5.5 --- CodePush.js | 42 +++-- docs/api-js.md | 28 ++- src/__tests__/package-metadata.test.ts | 18 ++ src/package-metadata.ts | 8 + src/react/store-context.ts | 2 +- src/react/use-download-progress.ts | 14 ++ .../__tests__/track-update.test.ts | 63 +++++++ .../__tests__/update-state.test.ts | 165 +++++++++++++++++- .../__tests__/update-store.test.ts | 15 ++ src/update-state/track-update.ts | 28 +++ src/update-state/update-state.ts | 86 +++++++-- src/update-state/update-store.ts | 10 +- typings/react-native-code-push.d.ts | 30 +++- 13 files changed, 460 insertions(+), 49 deletions(-) create mode 100644 src/__tests__/package-metadata.test.ts create mode 100644 src/package-metadata.ts create mode 100644 src/react/use-download-progress.ts create mode 100644 src/update-state/__tests__/track-update.test.ts create mode 100644 src/update-state/track-update.ts diff --git a/CodePush.js b/CodePush.js index c48a1367..becbbaf6 100644 --- a/CodePush.js +++ b/CodePush.js @@ -1,8 +1,11 @@ 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 { useDownloadProgress } from "./lib/commonjs/react/use-download-progress"; import { createUpdateStore } from "./lib/commonjs/update-state/update-store"; +import { trackDownload, trackInstall } from "./lib/commonjs/update-state/track-update"; import { onceUntilRejected } from "./lib/commonjs/once-until-rejected"; +import { packageMetadata } from "./lib/commonjs/package-metadata"; import { Alert } from "./AlertAdapter"; import requestFetchAdapter from "./request-fetch-adapter"; import { AppState, NativeEventEmitter, Platform } from "react-native"; @@ -30,20 +33,20 @@ async function withTimeout(promise, timeoutMs) { // Downloads a remote package, augmenting it with a bound install() method // beyond what's included in the metadata sent by the server. -async function downloadUpdate(remotePackage, downloadProgressCallback, reportStatusDownload) { +function downloadUpdate(remotePackage, downloadProgressCallback, reportStatusDownload) { + return trackDownload(updateStore, () => downloadUpdateInternal(remotePackage, downloadProgressCallback, reportStatusDownload)); +} + +async function downloadUpdateInternal(remotePackage, downloadProgressCallback, reportStatusDownload) { if (!remotePackage.downloadUrl) { throw new Error("Cannot download an update without a download url"); } - let downloadProgressSubscription; - if (downloadProgressCallback) { - const codePushEventEmitter = new NativeEventEmitter(NativeCodePush); - // Use event subscription to obtain download progress. - downloadProgressSubscription = codePushEventEmitter.addListener( - "CodePushDownloadProgress", - downloadProgressCallback - ); - } + const codePushEventEmitter = new NativeEventEmitter(NativeCodePush); + const downloadProgressSubscription = codePushEventEmitter.addListener("CodePushDownloadProgress", (progress) => { + updateStore.dispatch({ type: "downloadProgressed", progress }); + downloadProgressCallback && downloadProgressCallback(progress); + }); log.info(`Downloading update. ${logFields({ ...packageLogFields(remotePackage), packageSize: remotePackage.packageSize, url: redactUrl(remotePackage.downloadUrl) })}`); const downloadStartTime = Date.now(); @@ -61,12 +64,12 @@ async function downloadUpdate(remotePackage, downloadProgressCallback, reportSta // Use the downloaded package info. Native code will save the package info // so that the client knows what the current package version is. try { - const updatePackageCopy = Object.assign({}, remotePackage); - Object.keys(updatePackageCopy).forEach((key) => (typeof updatePackageCopy[key] === 'function') && delete updatePackageCopy[key]); + // Functions cannot cross the bridge. + const updatePackageCopy = packageMetadata(remotePackage); let downloadedPackage; try { - downloadedPackage = await NativeCodePush.downloadUpdate(updatePackageCopy, !!downloadProgressCallback); + downloadedPackage = await NativeCodePush.downloadUpdate(updatePackageCopy, true); } catch (err) { await reportDownloadStatus(DownloadStatus.Failed, undefined, err?.userInfo?.updateType); throw err; @@ -78,7 +81,7 @@ async function downloadUpdate(remotePackage, downloadProgressCallback, reportSta return attachLocalPackageMethods({ ...downloadedPackage, isPending: false }); // A freshly downloaded package hasn't been installed yet } finally { - downloadProgressSubscription && downloadProgressSubscription.remove(); + downloadProgressSubscription.remove(); } } @@ -246,9 +249,8 @@ async function getCurrentPackage() { async function installUpdate(localPackage, installMode = NativeCodePush.codePushInstallModeOnNextRestart, minimumBackgroundDuration = 0, updateInstalledCallback) { const localPackageCopy = Object.assign({}, localPackage); // In dev mode, React Native deep freezes any object queued over the bridge log.info(`Installing update. ${logFields({ ...packageLogFields(localPackage), installMode: installModeName(installMode), minimumBackgroundDuration })}`); - await NativeCodePush.installUpdate(localPackageCopy, installMode, minimumBackgroundDuration); + await trackInstall(updateStore, localPackage, () => 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); @@ -480,6 +482,11 @@ function setUpTestDependencies(testSdk, providedTestConfig, testNativeBridge) { if (testNativeBridge) NativeCodePush = testNativeBridge; } +function clearUpdates() { + NativeCodePush.clearUpdates(); + updateStore.dispatch({ type: "updatesCleared" }); +} + async function restartApp(onlyIfUpdateIsPending = false) { NativeCodePush.restartApp(onlyIfUpdateIsPending); } @@ -762,7 +769,7 @@ if (NativeCodePush) { sync, disallowRestart: NativeCodePush.disallow, allowRestart: NativeCodePush.allow, - clearUpdates: NativeCodePush.clearUpdates, + clearUpdates, InstallMode: { 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 @@ -836,4 +843,5 @@ if (NativeCodePush) { }, }); module.exports.useCodePush = useCodePush; + module.exports.useDownloadProgress = useDownloadProgress; } diff --git a/docs/api-js.md b/docs/api-js.md index f1bfbddd..0db0d5a8 100644 --- a/docs/api-js.md +++ b/docs/api-js.md @@ -68,12 +68,16 @@ If a prop changes after the first render, the next check on resume and the next ```tsx import { useCodePush } from "@bitrise/code-push-sdk"; -const { isChecking, isUpdateAvailable, availableUpdate, incompatibleUpdate, checkError, checkForUpdate, sync, restartApp } = useCodePush(); +const { + isChecking, isUpdateAvailable, availableUpdate, incompatibleUpdate, checkError, + isDownloading, installError, pendingUpdate, + 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. +The state shows every update check, download and install: the Provider's, and those of [`checkForUpdate`](#codepushcheckforupdate), [`sync`](#codepushsync), [`RemotePackage.download`](#remotepackage), [`LocalPackage.install`](#localpackage) and the `codePush()` higher-order component. It starts empty each time the app starts. * __isChecking__ *(Boolean)* - A check for an update runs. @@ -85,13 +89,19 @@ The state shows every update check: the Provider's, and those of [`checkForUpdat * __checkError__ *(Error)* - Why the last check failed. A new check clears it. +* __isDownloading__ *(Boolean)* - An update downloads. Read its progress with [`useDownloadProgress`](#usedownloadprogress). + +* __installError__ *(Error)* - Why the last attempt to install an update failed, in its download or in its install. A new download, or a successful install, clears it. The update stays in `availableUpdate`, so the user can try again. + +* __pendingUpdate__ *([LocalPackage](#localpackage), without `install`)* - An update that this app session installed, but that runs only after the app restarts. Show a "Restart to update" prompt, and apply it with `restartApp(true)`. An install with `InstallMode.IMMEDIATE` also sets it, for the time until the restart (longer, while [`disallowRestart`](#codepushdisallowrestart) is in effect). + * __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. +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: the state reports the check, the download and the install. If you still need a status callback, call `codePush.sync` directly: it updates the state too. Example: a "Check for updates" button on a settings screen. @@ -116,6 +126,18 @@ function CheckForUpdatesButton() { } ``` +#### useDownloadProgress + +```tsx +import { useDownloadProgress } from "@bitrise/code-push-sdk"; + +const progress = useDownloadProgress(); // { receivedBytes, totalBytes } or undefined +``` + +Returns the progress (`{ receivedBytes, totalBytes }`) of the running download, or `undefined` before the first progress event of a download, and after a download fails. After a successful download, it keeps the last progress until the next download starts. Check `isDownloading` of [`useCodePush`](#usecodepush) to know whether a download runs. `totalBytes` is -1 when the server does not send the size, for example for a compressed response: show a spinner instead of a percentage then. + +It is separate from `useCodePush`, because it changes many times per second during a download: only the components that call it re-render that often. Like `useCodePush`, it throws outside [`CodePushProvider`](#codepushprovider). + #### 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): diff --git a/src/__tests__/package-metadata.test.ts b/src/__tests__/package-metadata.test.ts new file mode 100644 index 00000000..0d8b8bf6 --- /dev/null +++ b/src/__tests__/package-metadata.test.ts @@ -0,0 +1,18 @@ +import * as assert from 'assert'; + +import { packageMetadata } from '../package-metadata'; + +describe('packageMetadata', () => { + it('copies every value but the functions, and leaves the package as it was', () => { + const download = () => Promise.resolve(); + const pkg = { label: 'v2', packageSize: 0, isMandatory: false, description: '', download }; + + assert.deepStrictEqual(packageMetadata(pkg), { + label: 'v2', + packageSize: 0, + isMandatory: false, + description: '', + }); + assert.strictEqual(pkg.download, download); + }); +}); diff --git a/src/package-metadata.ts b/src/package-metadata.ts new file mode 100644 index 00000000..c36b485c --- /dev/null +++ b/src/package-metadata.ts @@ -0,0 +1,8 @@ +type PackageMetadata = Omit; + +/** A copy of a package without its bound methods, `download()` and `install()`. */ +export function packageMetadata(pkg: T): PackageMetadata { + return Object.fromEntries( + Object.entries(pkg).filter(([, value]) => typeof value !== 'function'), + ) as PackageMetadata; +} diff --git a/src/react/store-context.ts b/src/react/store-context.ts index 658123f4..c40a2f57 100644 --- a/src/react/store-context.ts +++ b/src/react/store-context.ts @@ -8,7 +8,7 @@ export type UpdateActions = Pick; + store: Pick; actions: UpdateActions; } diff --git a/src/react/use-download-progress.ts b/src/react/use-download-progress.ts new file mode 100644 index 00000000..bb0a3328 --- /dev/null +++ b/src/react/use-download-progress.ts @@ -0,0 +1,14 @@ +import { useContext, useSyncExternalStore } from 'react'; + +import type { DownloadProgress } from '../../typings/react-native-code-push'; +import { StoreContext } from './store-context'; + +// Separate from useCodePush(), because progress changes up to once per frame during a download. Only the components +// that show it re-render that often. +export function useDownloadProgress(): DownloadProgress | undefined { + const context = useContext(StoreContext); + if (!context) { + throw new Error('useDownloadProgress must be used inside .'); + } + return useSyncExternalStore(context.store.subscribe, context.store.getDownloadProgress); +} diff --git a/src/update-state/__tests__/track-update.test.ts b/src/update-state/__tests__/track-update.test.ts new file mode 100644 index 00000000..9bde9536 --- /dev/null +++ b/src/update-state/__tests__/track-update.test.ts @@ -0,0 +1,63 @@ +import * as assert from 'assert'; + +import type { LocalPackage } from '../../../typings/react-native-code-push'; +import { trackDownload, trackInstall } from '../track-update'; +import type { UpdateEvent } from '../update-state'; + +function recorder() { + const events: UpdateEvent[] = []; + return { events, store: { dispatch: (event: UpdateEvent) => events.push(event) } }; +} + +describe('trackDownload', () => { + it('reports a download that completes, and returns its result', async () => { + const { events, store } = recorder(); + + assert.strictEqual(await trackDownload(store, async () => 'package'), 'package'); + assert.deepStrictEqual( + events.map((e) => e.type), + ['downloadStarted', 'downloadCompleted'], + ); + }); + + it('reports a download that fails, and rethrows its error', async () => { + const { events, store } = recorder(); + const error = new Error('network'); + + await assert.rejects( + trackDownload(store, () => Promise.reject(error)), + error, + ); + assert.deepStrictEqual(events, [{ type: 'downloadStarted' }, { type: 'downloadFailed', error }]); + }); +}); + +describe('trackInstall', () => { + const localPackage = { + label: 'v2', + packageHash: 'hash', + isPending: false, + install: () => Promise.resolve(), + } as unknown as LocalPackage; + + it('reports a pending copy of the package, without its methods', async () => { + const { events, store } = recorder(); + + await trackInstall(store, localPackage, () => Promise.resolve()); + + assert.deepStrictEqual(events, [ + { type: 'updateInstalled', update: { label: 'v2', packageHash: 'hash', isPending: true } }, + ]); + }); + + it('reports a failed install, and rethrows its error', async () => { + const { events, store } = recorder(); + const error = new Error('disk'); + + await assert.rejects( + trackInstall(store, localPackage, () => Promise.reject(error)), + error, + ); + assert.deepStrictEqual(events, [{ type: 'installFailed', error }]); + }); +}); diff --git a/src/update-state/__tests__/update-state.test.ts b/src/update-state/__tests__/update-state.test.ts index c1814b37..6c88d1cc 100644 --- a/src/update-state/__tests__/update-state.test.ts +++ b/src/update-state/__tests__/update-state.test.ts @@ -1,20 +1,35 @@ import * as assert from 'assert'; import type { RemotePackage } from '../../../typings/react-native-code-push'; -import { initialState, reduce, StoreState, UpdateEvent } from '../update-state'; +import { initialState, PendingUpdate, 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 update = { + label: 'v2', + packageHash: 'hash', + failedInstall: false, +} as RemotePackage; +const installed = { + label: 'v2', + packageHash: 'hash', + isPending: true, +} as PendingUpdate; const started: UpdateEvent = { type: 'checkStarted' }; +const downloadStarted: UpdateEvent = { type: 'downloadStarted' }; describe('reduce', () => { - it('starts idle, with no update and no error', () => { + it('starts idle, with no update, no download and no error', () => { assert.deepStrictEqual(initialState, { runningChecks: 0, - snapshot: { isChecking: false, isUpdateAvailable: false }, + runningDownloads: 0, + snapshot: { + isChecking: false, + isUpdateAvailable: false, + isDownloading: false, + }, }); }); @@ -128,17 +143,23 @@ describe('reduce', () => { const { snapshot } = reduceAll([ started, { type: 'checkCompleted', update }, - { type: 'updateInstalled', packageHash: 'hash' }, + { type: 'updateInstalled', update: installed }, ]); 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 }]); + it('installing another update keeps the available one, and makes the installed one pending', () => { + const other = { ...installed, packageHash: 'other' }; + const { snapshot } = reduceAll([ + started, + { type: 'checkCompleted', update }, + { type: 'updateInstalled', update: other }, + ]); - assert.strictEqual(reduce(found, { type: 'updateInstalled', packageHash: 'other' }), found); + assert.strictEqual(snapshot.availableUpdate, update); + assert.strictEqual(snapshot.pendingUpdate, other); }); it('does not change the state it is given', () => { @@ -147,4 +168,132 @@ describe('reduce', () => { assert.deepStrictEqual(initialState, before); }); + + describe('download', () => { + it('downloadStarted sets isDownloading, and downloadCompleted clears it', () => { + const downloading = reduce(initialState, downloadStarted); + assert.strictEqual(downloading.snapshot.isDownloading, true); + + const done = reduce(downloading, { type: 'downloadCompleted' }); + assert.strictEqual(done.snapshot.isDownloading, false); + assert.strictEqual(done.snapshot.installError, undefined); + }); + + it('downloadFailed clears isDownloading and sets installError, and keeps the available update', () => { + const error = new Error('network'); + const { snapshot } = reduceAll([ + started, + { type: 'checkCompleted', update }, + downloadStarted, + { type: 'downloadFailed', error }, + ]); + + assert.strictEqual(snapshot.isDownloading, false); + assert.strictEqual(snapshot.installError, error); + assert.strictEqual(snapshot.availableUpdate, update, 'so the user can retry'); + }); + + it('a new download clears installError, whether the download or the install failed', () => { + const failures: UpdateEvent[] = [ + { type: 'downloadFailed', error: new Error('network') }, + { type: 'installFailed', error: new Error('disk') }, + ]; + for (const failure of failures) { + const failed = reduceAll([downloadStarted, failure]); + assert.notStrictEqual(failed.snapshot.installError, undefined); + assert.strictEqual(reduce(failed, downloadStarted).snapshot.installError, undefined); + } + }); + + it('a new check keeps installError', () => { + const error = new Error('network'); + const { snapshot } = reduceAll([ + downloadStarted, + { type: 'downloadFailed', error }, + started, + { type: 'checkCompleted', update }, + ]); + + assert.strictEqual(snapshot.installError, error); + }); + + it('isDownloading stays true until every overlapping download ends', () => { + const two = reduceAll([downloadStarted, downloadStarted]); + assert.strictEqual(two.runningDownloads, 2); + + const one = reduce(two, { type: 'downloadCompleted' }); + assert.strictEqual(one.snapshot.isDownloading, true); + + const none = reduce(one, { + type: 'downloadFailed', + error: new Error('network'), + }); + assert.strictEqual(none.snapshot.isDownloading, false); + }); + + it('progress changes only downloadProgress, not the snapshot', () => { + const downloading = reduce(initialState, downloadStarted); + const progress = { receivedBytes: 10, totalBytes: 100 }; + const progressed = reduce(downloading, { + type: 'downloadProgressed', + progress, + }); + + assert.strictEqual(progressed.downloadProgress, progress); + assert.strictEqual(progressed.snapshot, downloading.snapshot); + }); + + it('a new or failed download clears the progress, and a completed one keeps it at its last value', () => { + const progress = { receivedBytes: 100, totalBytes: 100 }; + const progressed = reduceAll([downloadStarted, { type: 'downloadProgressed', progress }]); + + assert.strictEqual(reduce(progressed, downloadStarted).downloadProgress, undefined); + assert.strictEqual( + reduce(progressed, { + type: 'downloadFailed', + error: new Error('network'), + }).downloadProgress, + undefined, + ); + assert.strictEqual(reduce(progressed, { type: 'downloadCompleted' }).downloadProgress, progress); + }); + }); + + describe('install', () => { + it('installFailed sets installError, and a successful install clears it', () => { + const error = new Error('disk'); + const failed = reduce(initialState, { type: 'installFailed', error }); + assert.strictEqual(failed.snapshot.installError, error); + + assert.strictEqual( + reduce(failed, { type: 'updateInstalled', update: installed }).snapshot.installError, + undefined, + ); + }); + + it('an installed update is pending', () => { + const { snapshot } = reduce(initialState, { + type: 'updateInstalled', + update: installed, + }); + + assert.strictEqual(snapshot.pendingUpdate, installed); + }); + + it('a check keeps the pending update, as only a restart applies it', () => { + const { snapshot } = reduceAll([ + { type: 'updateInstalled', update: installed }, + started, + { type: 'checkCompleted', update: null }, + ]); + + assert.strictEqual(snapshot.pendingUpdate, installed); + }); + + it('clearing the updates removes the pending update', () => { + const { snapshot } = reduceAll([{ type: 'updateInstalled', update: installed }, { type: 'updatesCleared' }]); + + assert.strictEqual(snapshot.pendingUpdate, undefined); + }); + }); }); diff --git a/src/update-state/__tests__/update-store.test.ts b/src/update-state/__tests__/update-store.test.ts index bec9f176..de7bb18f 100644 --- a/src/update-state/__tests__/update-store.test.ts +++ b/src/update-state/__tests__/update-store.test.ts @@ -70,4 +70,19 @@ describe('createUpdateStore', () => { first.dispatch({ type: 'checkStarted' }); assert.strictEqual(second.getSnapshot().isChecking, false); }); + + it('notifies subscribers of download progress, without changing the snapshot', () => { + const store = createUpdateStore(); + store.dispatch({ type: 'downloadStarted' }); + let calls = 0; + store.subscribe(() => calls++); + const snapshot = store.getSnapshot(); + const progress = { receivedBytes: 1, totalBytes: 2 }; + + store.dispatch({ type: 'downloadProgressed', progress }); + + assert.strictEqual(calls, 1); + assert.strictEqual(store.getDownloadProgress(), progress); + assert.strictEqual(store.getSnapshot(), snapshot); + }); }); diff --git a/src/update-state/track-update.ts b/src/update-state/track-update.ts new file mode 100644 index 00000000..6c719dab --- /dev/null +++ b/src/update-state/track-update.ts @@ -0,0 +1,28 @@ +import type { LocalPackage } from '../../typings/react-native-code-push'; +import { packageMetadata } from '../package-metadata'; +import type { UpdateStore } from './update-store'; + +type Dispatch = Pick; + +export async function trackDownload(store: Dispatch, download: () => Promise): Promise { + store.dispatch({ type: 'downloadStarted' }); + try { + const result = await download(); + store.dispatch({ type: 'downloadCompleted' }); + return result; + } catch (error) { + store.dispatch({ type: 'downloadFailed', error: error as Error }); + throw error; + } +} + +export async function trackInstall(store: Dispatch, localPackage: LocalPackage, install: () => Promise) { + try { + await install(); + } catch (error) { + store.dispatch({ type: 'installFailed', error: error as Error }); + throw error; + } + // A copy, because the caller changes the package after the install. + store.dispatch({ type: 'updateInstalled', update: { ...packageMetadata(localPackage), isPending: true } }); +} diff --git a/src/update-state/update-state.ts b/src/update-state/update-state.ts index 98c249a5..975b02ac 100644 --- a/src/update-state/update-state.ts +++ b/src/update-state/update-state.ts @@ -1,22 +1,44 @@ -import type { CodePushState, IncompatibleUpdate, RemotePackage } from '../../typings/react-native-code-push'; +import type { + CodePushState, + DownloadProgress, + IncompatibleUpdate, + RemotePackage, +} from '../../typings/react-native-code-push'; + +export type PendingUpdate = NonNullable; export type UpdateEvent = | { type: 'checkStarted' } | { type: 'checkCompleted'; update: RemotePackage | null; incompatibleUpdate?: IncompatibleUpdate } | { type: 'checkFailed'; error: Error } - | { type: 'updateInstalled'; packageHash: string }; + | { type: 'downloadStarted' } + | { type: 'downloadProgressed'; progress: DownloadProgress } + | { type: 'downloadCompleted' } + | { type: 'downloadFailed'; error: Error } + | { type: 'installFailed'; error: Error } + | { type: 'updateInstalled'; update: PendingUpdate } + | { type: 'updatesCleared' }; export interface StoreState { /** A button check can overlap a resume sync, so `isChecking` stays true until all end. */ runningChecks: number; + /** sync() runs one download at a time, but `download()` of a RemotePackage can overlap it. */ + runningDownloads: number; snapshot: CodePushState; + /** + * Outside the snapshot, because it changes up to once per frame. Only the components that read it with + * useDownloadProgress() re-render that often. + */ + downloadProgress?: DownloadProgress; } export const initialState: StoreState = { runningChecks: 0, + runningDownloads: 0, snapshot: { isChecking: false, isUpdateAvailable: false, + isDownloading: false, }, }; @@ -24,30 +46,62 @@ export function reduce(state: StoreState, event: UpdateEvent): StoreState { switch (event.type) { case 'checkStarted': // The result of the previous check stays until this one completes. - return next(state, state.runningChecks + 1, { checkError: undefined }); + return next({ ...state, runningChecks: state.runningChecks + 1 }, { checkError: undefined }); case 'checkCompleted': { // sync() skips a previously rolled-back update by default, so it does not count as available. const update = event.update && !event.update.failedInstall ? event.update : undefined; - return next(state, state.runningChecks - 1, { - isUpdateAvailable: !!update, - availableUpdate: update, - incompatibleUpdate: event.incompatibleUpdate, - checkError: undefined, - }); + return next( + { ...state, runningChecks: state.runningChecks - 1 }, + { + isUpdateAvailable: !!update, + availableUpdate: update, + incompatibleUpdate: event.incompatibleUpdate, + checkError: undefined, + }, + ); } case 'checkFailed': - return next(state, state.runningChecks - 1, { checkError: event.error }); - case 'updateInstalled': + return next({ ...state, runningChecks: state.runningChecks - 1 }, { checkError: event.error }); + case 'downloadStarted': + // A new attempt clears the errors of the previous one. + return next( + { ...state, runningDownloads: state.runningDownloads + 1, downloadProgress: undefined }, + { installError: undefined }, + ); + case 'downloadProgressed': + return { ...state, downloadProgress: event.progress }; + case 'downloadCompleted': + return next({ ...state, runningDownloads: state.runningDownloads - 1 }, {}); + case 'downloadFailed': + return next( + { ...state, runningDownloads: state.runningDownloads - 1, downloadProgress: undefined }, + { installError: event.error }, + ); + case 'installFailed': + return next(state, { installError: event.error }); + case 'updateInstalled': { + const changes: Partial = { pendingUpdate: event.update, installError: undefined }; // Only the update it installed: a newer check may have found another one meanwhile. - if (state.snapshot.availableUpdate?.packageHash !== event.packageHash) { - return state; + if (state.snapshot.availableUpdate?.packageHash === event.update.packageHash) { + changes.isUpdateAvailable = false; + changes.availableUpdate = undefined; } - return next(state, state.runningChecks, { isUpdateAvailable: false, availableUpdate: undefined }); + return next(state, changes); + } + case 'updatesCleared': + return next(state, { pendingUpdate: undefined }); } } -function next(state: StoreState, runningChecks: number, changes: Partial): StoreState { - return { runningChecks, snapshot: updateSnapshot(state.snapshot, { ...changes, isChecking: runningChecks > 0 }) }; +function next(state: StoreState, changes: Partial): StoreState { + return { + ...state, + snapshot: updateSnapshot(state.snapshot, { + ...changes, + isChecking: state.runningChecks > 0, + isDownloading: state.runningDownloads > 0, + }), + }; } /** Keeps the snapshot identity when no value changes, so subscribers are not notified. */ diff --git a/src/update-state/update-store.ts b/src/update-state/update-store.ts index 1226cee4..eb60f64b 100644 --- a/src/update-state/update-store.ts +++ b/src/update-state/update-store.ts @@ -1,10 +1,11 @@ -import type { CodePushState } from '../../typings/react-native-code-push'; +import type { CodePushState, DownloadProgress } from '../../typings/react-native-code-push'; import { initialState, reduce, UpdateEvent } from './update-state'; // An external store, not React state, because the legacy non-React functions in CodePush.js also update it. -// getSnapshot() and subscribe() have the shape that React's useSyncExternalStore expects. +// getSnapshot(), getDownloadProgress() and subscribe() have the shape that React's useSyncExternalStore expects. export interface UpdateStore { getSnapshot(): CodePushState; + getDownloadProgress(): DownloadProgress | undefined; subscribe(listener: () => void): () => void; dispatch(event: UpdateEvent): void; } @@ -16,6 +17,7 @@ export function createUpdateStore(): UpdateStore { // Closures, not methods: useSyncExternalStore calls them unbound. return { getSnapshot: () => state.snapshot, + getDownloadProgress: () => state.downloadProgress, subscribe: (listener) => { listeners.add(listener); return () => { @@ -25,9 +27,11 @@ export function createUpdateStore(): UpdateStore { dispatch: (event) => { const previous = state; state = reduce(state, event); - if (state.snapshot === previous.snapshot) { + if (state.snapshot === previous.snapshot && state.downloadProgress === previous.downloadProgress) { return; } + // Every listener hears every change. useSyncExternalStore re-renders a component only when the value it reads + // changes, so a progress event does not re-render a component that only calls useCodePush(). // A copy, so a listener that unsubscribes during the loop does not skip the next one. Array.from(listeners).forEach((listener) => listener()); }, diff --git a/typings/react-native-code-push.d.ts b/typings/react-native-code-push.d.ts index a68b191f..ded24317 100644 --- a/typings/react-native-code-push.d.ts +++ b/typings/react-native-code-push.d.ts @@ -14,7 +14,8 @@ export interface CodePushOptions extends SyncOptions { export interface DownloadProgress { /** - * The total number of bytes expected to be received for this update. + * The total number of bytes expected to be received for this update, or -1 when the server does not send the size + * (for example, a compressed response without a Content-Length). */ totalBytes: number; @@ -519,6 +520,23 @@ export interface CodePushState { * Why the last check failed. A new check clears it. */ checkError?: Error; + + /** + * Whether an update is currently downloading. Read the progress with `useDownloadProgress()`. + */ + isDownloading: boolean; + + /** + * Why the last attempt to install an update failed, in its download or in its install. A new download, or a + * successful install, clears it. The update stays in `availableUpdate`, so the user can try again. + */ + installError?: Error; + + /** + * An update that this app session installed, but that runs only after the app restarts. Use it to show a + * "Restart to update" prompt, and call `restartApp(true)` to apply it. `clearUpdates()` removes it. + */ + pendingUpdate?: Omit; } export interface IncompatibleUpdate { @@ -552,4 +570,14 @@ export interface UseCodePushResult extends CodePushState { */ export function useCodePush(): UseCodePushResult; +/** + * Returns the progress of the last download. It is `undefined` until a download reports progress, and after a download + * fails. After a successful download, it keeps the last progress until the next download starts, so check + * `isDownloading` of `useCodePush()` to know whether a download runs. `totalBytes` is -1 when the size is unknown. + * + * Separate from `useCodePush()`, because it changes many times per second during a download: only the components + * that call it re-render that often. Throws outside `CodePushProvider`. + */ +export function useDownloadProgress(): DownloadProgress | undefined; + export default CodePush;