From e893f2395bf6ac94092a20516f62d2bce76d9d0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliv=C3=A9r=20Falvai?= Date: Fri, 30 Oct 2026 08:22:52 +0100 Subject: [PATCH] Add A component for the app root that starts CodePush on mount: it runs sync() with its props, or only notifyAppReady() with checkFrequency MANUAL. It can check again on each resume, and reports every failure to onError. Co-Authored-By: Claude Sonnet 5.5 --- CodePush.js | 31 ++- docs/api-js.md | 59 ++++- eslint.config.mjs | 2 +- package-lock.json | 22 +- package.json | 6 +- src/acquisition-sdk/acquisition-sdk.ts | 13 +- .../__tests__/app-lifecycle.test.ts | 233 ++++++++++++++++++ src/app-lifecycle/app-lifecycle.ts | 57 +++++ logging.js => src/logging.ts | 28 +-- src/react/code-push-provider.tsx | 24 ++ test/template/index.js | 8 +- .../scenarios/updateNotifyApplicationReady.js | 14 +- tsconfig.json | 6 +- typings/react-native-code-push.d.ts | 29 +++ 14 files changed, 493 insertions(+), 39 deletions(-) create mode 100644 src/app-lifecycle/__tests__/app-lifecycle.test.ts create mode 100644 src/app-lifecycle/app-lifecycle.ts rename logging.js => src/logging.ts (59%) create mode 100644 src/react/code-push-provider.tsx diff --git a/CodePush.js b/CodePush.js index 86093d96..919d13a7 100644 --- a/CodePush.js +++ b/CodePush.js @@ -1,9 +1,10 @@ import { AcquisitionManager as Sdk, DownloadStatus } from "./lib/commonjs/acquisition-sdk/acquisition-sdk"; +import { createCodePushProvider } from "./lib/commonjs/react/code-push-provider"; import { onceUntilRejected } from "./lib/commonjs/once-until-rejected"; import { Alert } from "./AlertAdapter"; import requestFetchAdapter from "./request-fetch-adapter"; import { AppState, NativeEventEmitter, Platform } from "react-native"; -import log, { logFields, packageLogFields, redactUrl } from "./logging"; +import log, { logFields, packageLogFields, redactUrl } from "./lib/commonjs/logging"; import hoistStatics from 'hoist-non-react-statics'; let NativeCodePush = require("react-native").NativeModules.CodePush; @@ -317,11 +318,16 @@ function getPromisifiedSdk(requestFetchAdapter, config) { const notifyApplicationReady = onceUntilRejected(notifyApplicationReadyInternal); async function notifyApplicationReadyInternal() { - await NativeCodePush.notifyApplicationReady(); - const statusReport = await NativeCodePush.getNewStatusReport(); - statusReport && tryReportStatus(statusReport); // Don't wait for this to complete. + try { + await NativeCodePush.notifyApplicationReady(); + const statusReport = await NativeCodePush.getNewStatusReport(); + statusReport && tryReportStatus(statusReport); // Don't wait for this to complete. - return statusReport; + return statusReport; + } catch (error) { + log.error("notifyAppReady failed.", error); + throw error; + } } async function tryReportStatus(statusReport, retryOnAppResume) { @@ -512,7 +518,9 @@ async function syncInternal(options = {}, syncStatusChangeCallback, downloadProg mandatoryInstallMode: CodePush.InstallMode.IMMEDIATE, minimumBackgroundDuration: 0, updateDialog: null, - ...options + // A key set to undefined would replace its default (for example `mandatoryInstallMode: undefined` + // would turn IMMEDIATE into ON_NEXT_RESTART). + ...Object.fromEntries(Object.entries(options).filter(([, value]) => value !== undefined)) }; // The outcomes are logged below and in the API functions, whether or not a custom callback is passed. @@ -780,3 +788,14 @@ if (NativeCodePush) { } module.exports = CodePush; + +// Named exports, outside the legacy Object.assign namespace. In CommonJS they are still properties of the CodePush +// function, so they need the same native-module check (module.exports is undefined without it). +if (NativeCodePush) { + module.exports.CodePushProvider = createCodePushProvider({ + sync: (options) => CodePush.sync(options), + notifyAppReady: () => CodePush.notifyAppReady(), + appState: AppState, + CheckFrequency: CodePush.CheckFrequency, + }); +} diff --git a/docs/api-js.md b/docs/api-js.md index 9551b01d..e404ecd1 100644 --- a/docs/api-js.md +++ b/docs/api-js.md @@ -10,9 +10,62 @@ The following sections describe the shape and behavior of these APIs in detail: ### JavaScript API Reference +#### CodePushProvider + +```tsx +import { CodePushProvider } from "@bitrise/code-push-sdk"; + +{children} +``` + +The recommended way to integrate CodePush. Wrap your app's root in it: + +```tsx +import * as Sentry from "@sentry/react-native"; +import codePush, { CodePushProvider } from "@bitrise/code-push-sdk"; + +export default function App() { + return ( + Sentry.captureException(error)} + > + + + ); +} +``` + +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. + +*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.* + +Props: + +* __checkFrequency__ *(codePush.CheckFrequency)* - When to check for updates. Defaults to `codePush.CheckFrequency.ON_APP_START`. + + * __ON_APP_START__ - Check once, after the first render. + + * __ON_APP_RESUME__ - Check after the first render, and each time the app returns to the foreground. + + * __MANUAL__ - Never check on its own. The Provider only calls `notifyAppReady`. Check for updates yourself, for example from a "Check for updates" button. + +* __onError__ *(function(error: Error))* - Called once for each failure of the launch step or of a check on resume. Use it to send errors to a crash reporter. A failed update call that your own code makes rejects its promise instead, and does not reach `onError`. CodePush logs every failure to the console, with or without `onError`. + +* __deploymentKey__, __installMode__, __mandatoryInstallMode__, __minimumBackgroundDuration__, __updateDialog__, __rollbackRetryOptions__ - The [update options](#codepushoptions), with the same defaults. + +* __children__ - Your app. + +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. + #### codePush -Integrate CodePush by calling [`sync`](#codepushsync) from a `useEffect` in your root component. Below are some examples of ways you can set this up (you can pick one or even use a combination): +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): 1. **Silent sync on app start** *(the simplest, default behavior)*. Your app will automatically download available updates, and apply them the next time the app restarts (like the OS or end user killed it, or the device was restarted). This way, the entire update experience is "silent" to the end user, since they don't see any update prompt and/or "synthetic" app restarts. @@ -168,7 +221,7 @@ The default export, `codePush` above, is a single object. Every API described be It provides the following methods: -* [sync](#codepushsync): Allows checking for an update, downloading it and installing it, all with a single call. Unless you need custom UI and/or behavior, we recommend most developers to call this method from a `useEffect` in their root component when integrating CodePush into their apps. See the [setup examples](#codepush) above. +* [sync](#codepushsync): Allows checking for an update, downloading it and installing it, all with a single call. [`CodePushProvider`](#codepushprovider) calls it for you. You can also call it from a `useEffect` in your root component (see the [setup examples](#codepush) above), or from a button. * [allowRestart](#codepushallowrestart): Re-allows programmatic restarts to occur as a result of an update being installed, and optionally, immediately restarts the app if a pending update had attempted to restart the app while restarts were disallowed. This is an advanced API and is only necessary if your app explicitly disallowed restarts via the `disallowRestart` method. @@ -375,7 +428,7 @@ codePush.notifyAppReady(): Promise; Notifies the CodePush runtime that a freshly installed update should be considered successful, and therefore, an automatic client-side rollback isn't necessary. It is mandatory to call this function somewhere in the code of the updated bundle. Otherwise, when the app next restarts, the CodePush runtime will assume that the installed update has failed and roll back to the previous version. This behavior exists to help ensure that your end users aren't blocked by a broken update. -If you are using the `sync` function, and doing your update check on app start, then you don't need to manually call `notifyAppReady` since `sync` will call it for you. This behavior exists due to the assumption that the point at which `sync` is called in your app represents a good approximation of a successful startup. +If you are using [`CodePushProvider`](#codepushprovider), or the `sync` function with your update check on app start, then you don't need to manually call `notifyAppReady` since they call it for you. This behavior exists due to the assumption that the point at which `sync` is called in your app represents a good approximation of a successful startup. *NOTE: This method is also aliased as `notifyApplicationReady` (for backwards compatibility).* diff --git a/eslint.config.mjs b/eslint.config.mjs index 3812f699..1b39566d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,6 +1,6 @@ import bitrise from '@bitrise/eslint-plugin'; -const files = ['src/**/*.ts', 'test/**/*.mts']; +const files = ['src/**/*.ts', 'src/**/*.tsx', 'test/**/*.mts']; export default [ { ...bitrise.react, files }, diff --git a/package-lock.json b/package-lock.json index d5e51b5c..e7fb06ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@types/mocha": "^10.0.10", "@types/node": "^24.19.0", "@types/q": "^1.5.4", + "@types/react": "^19.1.1", "archiver": "latest", "code-push-plugin-testing-framework": "file:./code-push-plugin-testing-framework", "eslint": "^9.39.5", @@ -26,6 +27,9 @@ "react-native": "0.88.0", "react-native-builder-bob": "^0.43.1", "typescript": "^6.0.3" + }, + "peerDependencies": { + "react": ">=18.2.0" } }, "code-push-plugin-testing-framework": { @@ -3228,6 +3232,16 @@ "integrity": "sha512-hroOstUScF6zhIi+5+x0dzqrHA1EJi+Irri6b1fxolMTqqHIV/Cg77EtnQcZqZCu8hR3mX2BzIxN4/GzI68Kfw==", "dev": true }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -4627,6 +4641,13 @@ "node": ">= 8" } }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -8844,7 +8865,6 @@ "version": "19.3.1", "resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz", "integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==", - "dev": true, "license": "MIT", "peer": true, "engines": { diff --git a/package.json b/package.json index 7f8e56f8..11a67c63 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "test:unit:ios": "npm run test:unit:ios:codepush && npm run test:unit:ios:core", "test:unit:ios:codepush": "xcodebuild test -project ios/CodePush.xcodeproj -scheme CodePushTests -destination \"platform=iOS Simulator,id=$(xcrun simctl list devices available | grep -m1 iPhone | grep -oE '[0-9A-F-]{36}')\"", "test:unit:ios:core": "cd ios/CodePushCore && xcodebuild test -scheme CodePushCore -destination \"platform=iOS Simulator,id=$(xcrun simctl list devices available | grep -m1 iPhone | grep -oE '[0-9A-F-]{36}')\"", - "lint": "eslint --max-warnings 0 'src/**/*.ts' 'test/**/*.mts'", + "lint": "eslint --max-warnings 0 'src/**/*.ts' 'src/**/*.tsx' 'test/**/*.mts'", "lint:fix": "npm run lint -- --fix" }, "repository": { @@ -70,6 +70,9 @@ "dependencies": { "hoist-non-react-statics": "^3.3.2" }, + "peerDependencies": { + "react": ">=18.2.0" + }, "devDependencies": { "@babel/register": "^7.29.7", "@bitrise/eslint-plugin": "^4.1.0", @@ -77,6 +80,7 @@ "@types/mocha": "^10.0.10", "@types/node": "^24.19.0", "@types/q": "^1.5.4", + "@types/react": "^19.1.1", "archiver": "latest", "code-push-plugin-testing-framework": "file:./code-push-plugin-testing-framework", "eslint": "^9.39.5", diff --git a/src/acquisition-sdk/acquisition-sdk.ts b/src/acquisition-sdk/acquisition-sdk.ts index feaa6b07..0a78a98a 100644 --- a/src/acquisition-sdk/acquisition-sdk.ts +++ b/src/acquisition-sdk/acquisition-sdk.ts @@ -1,5 +1,6 @@ // Vendored from https://github.com/microsoft/code-push/blob/master/src/script/acquisition-sdk.ts (archived, MIT licensed) +import log from '../logging'; import { CodePushDeployStatusError, CodePushHttpError, CodePushPackageError } from './code-push-error'; import { DeploymentStatusReport, @@ -126,8 +127,6 @@ export class AcquisitionManager { private isRecoverable = (statusCode: number): boolean => statusCode >= 500 || statusCode === 408 || statusCode === 429; - // Logs with console directly: this file is compiled to lib/ separately, and cannot import the root logging.js. - // TODO: simplify this once we adopt TypeScript properly in the entire repo. private handleRequestFailure() { if ( this._serverUrl.includes(this.BASE_URL_PART) && @@ -135,8 +134,8 @@ export class AcquisitionManager { !AcquisitionManager._apiCallsDisabled ) { AcquisitionManager._apiCallsDisabled = true; - console.warn( - `[CodePush] The server returned HTTP ${this._statusCode}, so all later API calls are disabled for the rest of this app session.`, + log.warn( + `The server returned HTTP ${this._statusCode}, so all later API calls are disabled for the rest of this app session.`, ); } } @@ -146,7 +145,7 @@ export class AcquisitionManager { callback?: Callback, ): void { if (AcquisitionManager._apiCallsDisabled) { - console.log(`[CodePush] API calls are disabled, skipping the update_check request.`); + log.info(`API calls are disabled, skipping the update_check request.`); callback(/*error=*/ null, /*remotePackage=*/ null); return; } @@ -229,7 +228,7 @@ export class AcquisitionManager { callback?: Callback, ): void { if (AcquisitionManager._apiCallsDisabled) { - console.log(`[CodePush] API calls are disabled, skipping the report_status/deploy request.`); + log.info(`API calls are disabled, skipping the report_status/deploy request.`); callback(/*error*/ null, /*not used*/ null); return; } @@ -308,7 +307,7 @@ export class AcquisitionManager { public reportStatusDownload(downloadedPackage: DownloadedPackage, callback?: Callback): void { if (AcquisitionManager._apiCallsDisabled) { - console.log(`[CodePush] API calls are disabled, skipping the report_status/download request.`); + log.info(`API calls are disabled, skipping the report_status/download request.`); callback(/*error*/ null, /*not used*/ null); return; } diff --git a/src/app-lifecycle/__tests__/app-lifecycle.test.ts b/src/app-lifecycle/__tests__/app-lifecycle.test.ts new file mode 100644 index 00000000..c974085e --- /dev/null +++ b/src/app-lifecycle/__tests__/app-lifecycle.test.ts @@ -0,0 +1,233 @@ +import * as assert from 'assert'; + +import type { SyncOptions } from '../../../typings/react-native-code-push'; +import { AppLifecycleConfig, AppLifecycleDeps, startAppLifecycle } from '../app-lifecycle'; + +// The numbers are the public CheckFrequency values, but the controller only compares them with the injected ones. +const CheckFrequency = { + ON_APP_START: 0, + ON_APP_RESUME: 1, + MANUAL: 2, +} as unknown as AppLifecycleDeps['CheckFrequency'] & { + ON_APP_START: AppLifecycleConfig['checkFrequency']; +}; + +class FakeAppState { + private listeners = new Set<(state: string) => void>(); + + get listenerCount(): number { + return this.listeners.size; + } + + addEventListener(_type: 'change', listener: (state: string) => void): { remove(): void } { + this.listeners.add(listener); + return { remove: () => this.listeners.delete(listener) }; + } + + emit(state: string): void { + this.listeners.forEach((listener) => listener(state)); + } +} + +function flushPromises(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} + +describe('startAppLifecycle', () => { + let appState: FakeAppState; + let syncCalls: SyncOptions[]; + let notifyAppReadyCalls: number; + let syncImpl: () => Promise; + let notifyAppReadyImpl: () => Promise; + let errors: Error[]; + const onError = (error: Error): void => { + errors.push(error); + }; + + function start(getConfig: () => Partial): () => void { + return startAppLifecycle( + { + sync: (options) => { + syncCalls.push(options); + return syncImpl(); + }, + notifyAppReady: () => { + notifyAppReadyCalls++; + return notifyAppReadyImpl(); + }, + appState, + CheckFrequency, + }, + () => ({ syncOptions: {}, ...getConfig() }), + ); + } + + beforeEach(() => { + appState = new FakeAppState(); + syncCalls = []; + notifyAppReadyCalls = 0; + syncImpl = async () => undefined; + notifyAppReadyImpl = async () => undefined; + errors = []; + }); + + it('MANUAL calls only notifyAppReady', async () => { + const stop = start(() => ({ checkFrequency: CheckFrequency.MANUAL })); + await flushPromises(); + + assert.strictEqual(notifyAppReadyCalls, 1); + assert.strictEqual(syncCalls.length, 0); + stop(); + }); + + it('ON_APP_START calls sync once, and a resume only calls notifyAppReady', async () => { + const stop = start(() => ({ checkFrequency: CheckFrequency.ON_APP_START })); + await flushPromises(); + assert.strictEqual(syncCalls.length, 1); + + appState.emit('active'); + await flushPromises(); + + assert.strictEqual(syncCalls.length, 1); + assert.strictEqual(notifyAppReadyCalls, 1); + stop(); + }); + + it('without checkFrequency, behaves as ON_APP_START', async () => { + const stop = start(() => ({})); + await flushPromises(); + appState.emit('active'); + await flushPromises(); + + assert.strictEqual(syncCalls.length, 1); + assert.strictEqual(notifyAppReadyCalls, 1); + stop(); + }); + + it('ON_APP_RESUME checks again when the app becomes active, not on other state changes', async () => { + const stop = start(() => ({ checkFrequency: CheckFrequency.ON_APP_RESUME })); + await flushPromises(); + appState.emit('background'); + appState.emit('inactive'); + await flushPromises(); + assert.strictEqual(syncCalls.length, 1); + + appState.emit('active'); + await flushPromises(); + assert.strictEqual(syncCalls.length, 2); + stop(); + }); + + it('stop removes the listener, so a StrictMode mount, stop, mount leaves one', async () => { + const config = { checkFrequency: CheckFrequency.ON_APP_RESUME }; + + const stop1 = start(() => config); + stop1(); + const stop2 = start(() => config); + assert.strictEqual(appState.listenerCount, 1); + + stop2(); + assert.strictEqual(appState.listenerCount, 0); + + await flushPromises(); + appState.emit('active'); + await flushPromises(); + assert.strictEqual(syncCalls.length, 2, 'only the two launch syncs, no resume check after stop'); + }); + + it('resume checks and onError use the latest config', async () => { + const firstErrors: Error[] = []; + let config: Partial = { + checkFrequency: CheckFrequency.ON_APP_RESUME, + syncOptions: { deploymentKey: 'production' }, + onError: (error) => firstErrors.push(error), + }; + const stop = start(() => config); + await flushPromises(); + + config = { ...config, syncOptions: { deploymentKey: 'staging' }, onError }; + syncImpl = async () => { + throw new Error('network'); + }; + appState.emit('active'); + await flushPromises(); + + assert.strictEqual(syncCalls[0].deploymentKey, 'production'); + assert.strictEqual(syncCalls[1].deploymentKey, 'staging'); + assert.deepStrictEqual(firstErrors, []); + assert.deepStrictEqual( + errors.map((e) => e.message), + ['network'], + ); + stop(); + }); + + it('a resume does not check while checkFrequency is not ON_APP_RESUME, and checks after it changes', async () => { + let config: Partial = { checkFrequency: CheckFrequency.MANUAL }; + const stop = start(() => config); + await flushPromises(); + + appState.emit('active'); + await flushPromises(); + assert.strictEqual(syncCalls.length, 0); + + config = { checkFrequency: CheckFrequency.ON_APP_RESUME }; + appState.emit('active'); + await flushPromises(); + assert.strictEqual(syncCalls.length, 1); + stop(); + }); + + it('passes the sync options to sync', async () => { + const stop = start(() => ({ syncOptions: { installMode: 2, minimumBackgroundDuration: 60 } })); + await flushPromises(); + + assert.deepStrictEqual(syncCalls[0], { installMode: 2, minimumBackgroundDuration: 60 }); + stop(); + }); + + it('reports a failed sync to onError', async () => { + syncImpl = async () => { + throw new Error('check failed'); + }; + const stop = start(() => ({ onError })); + await flushPromises(); + + assert.deepStrictEqual( + errors.map((e) => e.message), + ['check failed'], + ); + stop(); + }); + + it('MANUAL reports a failed notifyAppReady to onError', async () => { + notifyAppReadyImpl = async () => { + throw new Error('native'); + }; + const stop = start(() => ({ checkFrequency: CheckFrequency.MANUAL, onError })); + await flushPromises(); + + assert.deepStrictEqual( + errors.map((e) => e.message), + ['native'], + ); + stop(); + }); + + it('retries a failed notifyAppReady on resume, without checking for an update', async () => { + notifyAppReadyImpl = async () => { + throw new Error('native'); + }; + const stop = start(() => ({ checkFrequency: CheckFrequency.MANUAL, onError })); + await flushPromises(); + + notifyAppReadyImpl = async () => undefined; + appState.emit('active'); + await flushPromises(); + + assert.strictEqual(notifyAppReadyCalls, 2); + assert.strictEqual(syncCalls.length, 0); + assert.strictEqual(errors.length, 1); + stop(); + }); +}); diff --git a/src/app-lifecycle/app-lifecycle.ts b/src/app-lifecycle/app-lifecycle.ts new file mode 100644 index 00000000..1b921fdd --- /dev/null +++ b/src/app-lifecycle/app-lifecycle.ts @@ -0,0 +1,57 @@ +import type CodePush from '../../typings/react-native-code-push'; +import type { CodePushProviderProps, SyncOptions } from '../../typings/react-native-code-push'; + +// The automatic part of : the launch step on mount and the checks on resume. It is React-free and +// gets everything from CodePush.js, so unit tests can drive it with fakes. + +export interface AppLifecycleConfig extends Pick { + syncOptions: SyncOptions; +} + +export interface AppLifecycleDeps { + sync(options: SyncOptions): Promise; + notifyAppReady(): Promise; + appState: { + addEventListener(type: 'change', listener: (state: string) => void): { remove(): void }; + }; + CheckFrequency: Pick; +} + +/** + * Runs the launch step and listens for resume checks. `getConfig` is read on each use, so prop changes apply to the + * next resume check and to `onError`. Returns a function that removes the `AppState` listener. + */ +export function startAppLifecycle(deps: AppLifecycleDeps, getConfig: () => AppLifecycleConfig): () => void { + async function attempt(operation: () => Promise): Promise { + try { + await operation(); + } catch (error) { + getConfig().onError?.(error as Error); + } + } + + const runSync = () => attempt(() => deps.sync(getConfig().syncOptions)); + const runNotifyAppReady = () => attempt(deps.notifyAppReady); + + if (getConfig().checkFrequency === deps.CheckFrequency.MANUAL) { + runNotifyAppReady(); + } else { + runSync(); + } + + // Always subscribe, so a later change of checkFrequency to ON_APP_RESUME also works. + const subscription = deps.appState.addEventListener('change', (state) => { + if (state !== 'active') { + return; + } + if (getConfig().checkFrequency === deps.CheckFrequency.ON_APP_RESUME) { + runSync(); + } else { + // Retries a failed notifyAppReady(), so the update is not rolled back on the next start. CodePush.js keeps a + // successful result, so after a success this does nothing. + runNotifyAppReady(); + } + }); + + return () => subscription.remove(); +} diff --git a/logging.js b/src/logging.ts similarity index 59% rename from logging.js rename to src/logging.ts index 6f942aec..dfbda2db 100644 --- a/logging.js +++ b/src/logging.ts @@ -4,44 +4,44 @@ * In dev builds, warn() and error() show LogBox toasts, so only use them for problems the app * developer has to act on (warn) or for failed operations (error). */ -const PREFIX = "[CodePush]"; +const PREFIX = '[CodePush]'; -function info(message) { +function info(message: string): void { console.log(`${PREFIX} ${message}`); } // The stack goes into the string itself: RN's console polyfill formats an Error argument as // "[Error: message]" before it goes to logcat / the Xcode log, so the stack is not in those logs. // (DevTools gets the Error object itself and shows the stack.) -function formatError(err) { - return err ? `\n${err.stack || err}` : ""; +function formatError(err?: Error): string { + return err ? `\n${err.stack || err}` : ''; } -function warn(message, err) { +function warn(message: string, err?: Error): void { console.warn(`${PREFIX} ${message}${formatError(err)}`); } -function error(message, err) { +function error(message: string, err?: Error): void { console.error(`${PREFIX} ${message}${formatError(err)}`); } -function logFields(fields) { +export function logFields(fields: Record): string { return Object.entries(fields) - .filter(([, value]) => value !== undefined && value !== null && value !== "") + .filter(([, value]) => value !== undefined && value !== null && value !== '') .map(([key, value]) => `${key}=${value}`) - .join(" "); + .join(' '); } -function packageLogFields(pkg) { +export function packageLogFields(pkg?: { versionLabel?: string; packageHash?: string }) { return { versionLabel: pkg && pkg.versionLabel, - packageHash: pkg && pkg.packageHash + packageHash: pkg && pkg.packageHash, }; } // Signed download URLs can have credentials in the query string. -function redactUrl(url) { - return url ? url.split("?")[0] : url; +export function redactUrl(url?: string): string | undefined { + return url ? url.split('?')[0] : url; } -module.exports = { info, warn, error, logFields, packageLogFields, redactUrl }; +export default { info, warn, error }; diff --git a/src/react/code-push-provider.tsx b/src/react/code-push-provider.tsx new file mode 100644 index 00000000..1ae56057 --- /dev/null +++ b/src/react/code-push-provider.tsx @@ -0,0 +1,24 @@ +import { useEffect, useRef } from 'react'; + +import type { CodePushProviderProps } from '../../typings/react-native-code-push'; +import { AppLifecycleDeps, startAppLifecycle } from '../app-lifecycle/app-lifecycle'; + +export function createCodePushProvider(deps: AppLifecycleDeps) { + 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. + useEffect(() => { + latest.current = { checkFrequency, onError, syncOptions }; + }); + + // Runs once per mount, after the whole tree commits, so a crash on the first render still rolls the update back. + // Prop changes do not run the launch step again. + useEffect(() => startAppLifecycle(deps, () => latest.current), []); + + return <>{children}; + } + + return CodePushProvider; +} diff --git a/test/template/index.js b/test/template/index.js index 57d8a450..4cb9e4c5 100644 --- a/test/template/index.js +++ b/test/template/index.js @@ -165,4 +165,10 @@ const styles = StyleSheet.create({ }, }); -AppRegistry.registerComponent('CODE_PUSH_TEST_APP_NAME', () => CODE_PUSH_TEST_APP_NAME); +// A scenario can export wrap(app) to put the app inside e.g. . +function Root() { + var app = ; + return testScenario.wrap ? testScenario.wrap(app) : app; +} + +AppRegistry.registerComponent('CODE_PUSH_TEST_APP_NAME', () => Root); diff --git a/test/template/scenarios/updateNotifyApplicationReady.js b/test/template/scenarios/updateNotifyApplicationReady.js index 735f6ba1..a3c4ad0d 100644 --- a/test/template/scenarios/updateNotifyApplicationReady.js +++ b/test/template/scenarios/updateNotifyApplicationReady.js @@ -1,10 +1,18 @@ -var CodePushWrapper = require("../codePushWrapper.js"); -import CodePush from "@bitrise/code-push-sdk"; +import React from "react"; +import CodePush, { CodePushProvider } from "@bitrise/code-push-sdk"; module.exports = { startTest: function (testApp) { testApp.readyAfterUpdate(); - CodePush.notifyAppReady(); + }, + + // The Provider's MANUAL launch step calls notifyAppReady(), so the update is kept. Without it, it rolls back. + wrap: function (app) { + return ( + + {app} + + ); }, getScenarioName: function () { diff --git a/tsconfig.json b/tsconfig.json index 2d4b8c2b..ba475e89 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,8 +22,10 @@ // Babel compiles the JS, so the compiler must not choke on syntax that needs whole-program information. "isolatedModules": true, "noEmit": true, - "rootDir": "src" + "rootDir": "src", + // The automatic runtime (react/jsx-runtime), as in @react-native/babel-preset that compiles the JS. + "jsx": "react-jsx" }, - "include": ["src/**/*.ts"], + "include": ["src/**/*.ts", "src/**/*.tsx"], "exclude": ["src/**/__tests__/**"] } diff --git a/typings/react-native-code-push.d.ts b/typings/react-native-code-push.d.ts index e641568c..6cdac242 100644 --- a/typings/react-native-code-push.d.ts +++ b/typings/react-native-code-push.d.ts @@ -1,3 +1,5 @@ +import type * as React from "react"; + export type DownloadProgressCallback = (progress: DownloadProgress) => void; export type SyncStatusChangedCallback = (status: CodePush.SyncStatus) => void; export type HandleBinaryVersionMismatchCallback = (update: RemotePackage) => void; @@ -465,4 +467,31 @@ declare namespace CodePush { } } +export interface CodePushProviderProps extends SyncOptions { + /** + * Specifies when you would like to synchronize updates with the CodePush server. + * Defaults to codePush.CheckFrequency.ON_APP_START. With codePush.CheckFrequency.MANUAL, the Provider + * only calls notifyAppReady(). + */ + checkFrequency?: CodePush.CheckFrequency; + + /** + * Called once for each failure of the work that the Provider starts (the launch step and the checks on resume). + * Errors from update calls that your own code makes reject their promise instead. + */ + onError?: (error: Error) => void; + + children?: React.ReactNode; +} + +/** + * Starts CodePush for the app: checks for updates when it mounts (and on resume, with + * `checkFrequency={CheckFrequency.ON_APP_RESUME}`), always calls notifyAppReady() so good updates are kept, and reports + * failures to `onError`. Place it at the app root, outside auth gates and screens that render late. + * + * Takes the same options as `CodePushOptions`, minus `checkFrequency`, which is optional here. Prop changes apply to + * the next check on resume. + */ +export function CodePushProvider(props: CodePushProviderProps): React.ReactElement | null; + export default CodePush;