From 3f189634e69a04a73043ae8ac762db753c4fb0a6 Mon Sep 17 00:00:00 2001 From: Himanshu Kumar Date: Mon, 18 May 2026 13:21:27 +0530 Subject: [PATCH] feat: unified component (v0.3.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a high-level component that wraps useUpdate + ForceUpdateModal + SuggestUpdateBanner so consumers can integrate with three props instead of six lines of verdict-routing boilerplate. - `thresholds` prop groups force/suggest version cutoffs — the relationship is unmistakable - `force` and `suggest` prop bags forward customisation to the inner modal/banner without prop-pollution at the top level - `showVersionDiff` auto-generates the "vX → vY" pill on the modal - No breaking changes — lower-level exports continue to work Co-authored-by: Claude --- CHANGELOG.md | 29 ++++++ README.md | 25 +++-- __tests__/UpdateGate.test.tsx | 172 ++++++++++++++++++++++++++++++++++ package.json | 2 +- src/components/UpdateGate.tsx | 77 +++++++++++++++ src/index.ts | 5 + 6 files changed, 299 insertions(+), 11 deletions(-) create mode 100644 __tests__/UpdateGate.test.tsx create mode 100644 src/components/UpdateGate.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index b7b3b1f..c1fc64a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.0] — 2026-05-18 + +### Added +- **New `` component** — unified high-level API that internally + uses `useUpdate` and renders the right child (`ForceUpdateModal` or + `SuggestUpdateBanner`) based on the verdict. Simplifies the most common + integration from ~6 lines to one component: + ```tsx + + ``` +- `thresholds` prop groups `force` (blocks below this version) and `suggest` + (gently prompts below this version) for unambiguous intent. +- `force` and `suggest` prop bags pass through customisation to the underlying + modal / banner — e.g. `force={{ title, message, buttonText }}`. +- `showVersionDiff` prop — auto-generates the `vX → vY` pill on the modal. +- New `UpdateThresholds` type exported for typed config objects. + +### Compatibility +- **No breaking changes**. `ForceUpdateModal`, `SuggestUpdateBanner`, `useUpdate`, + and all existing exports continue to work unchanged for consumers who need + per-component control (separate themes, custom positioning, etc.). + ## [0.2.2] — 2026-05-18 ### Changed diff --git a/README.md b/README.md index 50af083..fcb57fe 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Force users off broken old builds with **Google Play's native immediate flow** on Android, and a **polished animated modal** on iOS. Drive version thresholds from your own server, not from store metadata. Ship in 15 minutes. ```tsx -import { useUpdate, ForceUpdateModal, SuggestUpdateBanner, configureUpdateGate } from '@zethictech/react-native-update-gate'; +import { UpdateGate, configureUpdateGate } from '@zethictech/react-native-update-gate'; import DeviceInfo from 'react-native-device-info'; configureUpdateGate({ @@ -22,23 +22,28 @@ configureUpdateGate({ }); function App() { - const { verdict } = useUpdate({ - installed: DeviceInfo.getVersion(), - minRequired: serverConfig.min_app_version, - latestAvailable: serverConfig.latest_app_version, - }); - return ( <> - - + ); } ``` -That's the whole integration. +Three props. That's the whole integration. The component internally watches `AppState`, +re-evaluates on foreground, renders the right UI for the verdict, and handles dismissal. + +For advanced layouts (separate themes per component, custom positioning, etc.) the +lower-level ``, `` and `useUpdate()` exports +remain available — see [API reference](#api) below. --- diff --git a/__tests__/UpdateGate.test.tsx b/__tests__/UpdateGate.test.tsx new file mode 100644 index 0000000..28d2528 --- /dev/null +++ b/__tests__/UpdateGate.test.tsx @@ -0,0 +1,172 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; + +// Mock the inner components to avoid running their animation lifecycles. +// We only test UpdateGate's routing + prop-flow logic here; the children +// have their own dedicated tests. `require` is inline because jest.mock +// factories cannot reference out-of-scope variables. +jest.mock('../src/components/ForceUpdateModal', () => { + const RN = require('react-native'); + const ReactLib = require('react'); + return { + ForceUpdateModal: (props: Record) => + ReactLib.createElement( + RN.Text, + { testID: 'force-modal' }, + JSON.stringify({ + visible: props.visible, + accent: props.accent, + versionLabel: props.versionLabel, + title: props.title, + message: props.message, + buttonText: props.buttonText, + }), + ), + }; +}); + +jest.mock('../src/components/SuggestUpdateBanner', () => { + const RN = require('react-native'); + const ReactLib = require('react'); + return { + SuggestUpdateBanner: (props: Record) => + ReactLib.createElement( + RN.Text, + { testID: 'suggest-banner' }, + JSON.stringify({ + visible: props.visible, + accent: props.accent, + title: props.title, + position: props.position, + }), + ), + }; +}); + +// Import AFTER the mocks so UpdateGate picks up the stubs. +import { UpdateGate } from '../src/components/UpdateGate'; + +const parseProps = (json: string): Record => JSON.parse(json); + +describe('', () => { + it('does not render force modal or suggest banner when verdict is "none"', () => { + const { getByTestId } = render( + , + ); + expect(parseProps(getByTestId('force-modal').children.join('')).visible).toBe(false); + expect(parseProps(getByTestId('suggest-banner').children.join('')).visible).toBe(false); + }); + + it('renders force modal when installed < thresholds.force', () => { + const { getByTestId } = render( + , + ); + const props = parseProps(getByTestId('force-modal').children.join('')); + expect(props.visible).toBe(true); + }); + + it('renders suggest banner when installed at/above force but below suggest', () => { + const { getByTestId } = render( + , + ); + expect(parseProps(getByTestId('force-modal').children.join('')).visible).toBe(false); + expect(parseProps(getByTestId('suggest-banner').children.join('')).visible).toBe(true); + }); + + it('flows the accent prop to both children', () => { + const { getByTestId } = render( + , + ); + expect(parseProps(getByTestId('force-modal').children.join('')).accent).toBe('#FF0000'); + expect(parseProps(getByTestId('suggest-banner').children.join('')).accent).toBe('#FF0000'); + }); + + it('flows the force prop bag to the modal', () => { + const { getByTestId } = render( + , + ); + const props = parseProps(getByTestId('force-modal').children.join('')); + expect(props.title).toBe('Critical'); + expect(props.message).toBe('Update now'); + expect(props.buttonText).toBe('GO'); + }); + + it('flows the suggest prop bag to the banner', () => { + const { getByTestId } = render( + , + ); + const props = parseProps(getByTestId('suggest-banner').children.join('')); + expect(props.title).toBe('New version'); + expect(props.position).toBe('bottom'); + }); + + it('auto-generates the version-diff label by default', () => { + const { getByTestId } = render( + , + ); + expect(parseProps(getByTestId('force-modal').children.join('')).versionLabel).toBe( + 'v1.5.0 → v1.7.0', + ); + }); + + it('uses thresholds.force for the diff when suggest is unset', () => { + const { getByTestId } = render( + , + ); + expect(parseProps(getByTestId('force-modal').children.join('')).versionLabel).toBe( + 'v1.5.0 → v1.6.0', + ); + }); + + it('skips the version-diff label when showVersionDiff={false}', () => { + const { getByTestId } = render( + , + ); + // JSON.stringify drops undefined keys, so `versionLabel` doesn't appear. + expect(parseProps(getByTestId('force-modal').children.join('')).versionLabel).toBeUndefined(); + }); + + it('explicit force.versionLabel overrides the auto label', () => { + const { getByTestId } = render( + , + ); + expect(parseProps(getByTestId('force-modal').children.join('')).versionLabel).toBe( + 'Security patch', + ); + }); +}); diff --git a/package.json b/package.json index 464efce..47c33a0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@zethictech/react-native-update-gate", - "version": "0.2.5", + "version": "0.3.0", "description": "Beautiful, type-safe, server-driven force/suggest update gate for React Native. Uses Google Play's native immediate flow on Android; ships a polished animated modal on iOS.", "main": "lib/commonjs/index.js", "module": "lib/module/index.js", diff --git a/src/components/UpdateGate.tsx b/src/components/UpdateGate.tsx new file mode 100644 index 0000000..db569eb --- /dev/null +++ b/src/components/UpdateGate.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { useUpdate } from '../hooks/useUpdate'; +import { ForceUpdateModal, type ForceUpdateModalProps } from './ForceUpdateModal'; +import { + SuggestUpdateBanner, + type SuggestUpdateBannerProps, +} from './SuggestUpdateBanner'; +import type { ThemingProps } from '../types'; + +export interface UpdateThresholds { + /** Below this version, verdict is `'force'` — blocking modal renders. */ + force?: string; + /** Below this version (and at/above `force`), verdict is `'suggest'` — banner renders. */ + suggest?: string; +} + +export interface UpdateGateProps extends ThemingProps { + /** Currently installed app version, e.g. `DeviceInfo.getVersion()`. */ + installed: string; + /** Version thresholds. `force` blocks; `suggest` gently prompts. */ + thresholds: UpdateThresholds; + /** Re-check verdict when the app returns to foreground. Default `true`. */ + checkOnResume?: boolean; + /** Auto-trigger Android Play Core IMMEDIATE flow on `'force'`. Default `true`. */ + autoPresent?: boolean; + /** Force-modal customisation (title, message, buttonText, etc.). */ + force?: Omit; + /** Suggest-banner customisation (title, message, position, etc.). */ + suggest?: Omit; + /** Auto-build `vX → vY` pill on the force modal. Default `true`. */ + showVersionDiff?: boolean; +} + +export const UpdateGate: React.FC = ({ + installed, + thresholds, + checkOnResume, + autoPresent, + accent, + theme, + icon, + force, + suggest, + showVersionDiff = true, +}) => { + const { verdict } = useUpdate({ + installed, + minRequired: thresholds.force, + latestAvailable: thresholds.suggest, + checkOnResume, + autoPresent, + }); + + const target = thresholds.suggest ?? thresholds.force; + const autoVersionLabel = + showVersionDiff && target ? `v${installed} → v${target}` : undefined; + + return ( + <> + + + + ); +}; diff --git a/src/index.ts b/src/index.ts index 9d2b915..f9feb91 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,11 @@ export { SuggestUpdateBanner, type SuggestUpdateBannerProps, } from './components/SuggestUpdateBanner'; +export { + UpdateGate, + type UpdateGateProps, + type UpdateThresholds, +} from './components/UpdateGate'; export { UpdateGateThemeProvider, lightTheme,