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
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<UpdateGate>` 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
<UpdateGate
installed={DeviceInfo.getVersion()}
thresholds={{
force: serverConfig.min_app_version,
suggest: serverConfig.latest_app_version,
}}
accent="#FF6B6B"
/>
```
- `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
Expand Down
25 changes: 15 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -22,23 +22,28 @@ configureUpdateGate({
});

function App() {
const { verdict } = useUpdate({
installed: DeviceInfo.getVersion(),
minRequired: serverConfig.min_app_version,
latestAvailable: serverConfig.latest_app_version,
});

return (
<>
<Navigator />
<ForceUpdateModal visible={verdict === 'force'} />
<SuggestUpdateBanner visible={verdict === 'suggest'} />
<UpdateGate
installed={DeviceInfo.getVersion()}
thresholds={{
force: serverConfig.min_app_version, // installed < this → blocking modal
suggest: serverConfig.latest_app_version, // installed < this → dismissible banner
}}
accent="#FF6B6B"
/>
</>
);
}
```

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 `<ForceUpdateModal>`, `<SuggestUpdateBanner>` and `useUpdate()` exports
remain available — see [API reference](#api) below.

---

Expand Down
172 changes: 172 additions & 0 deletions __tests__/UpdateGate.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) =>
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<string, unknown>) =>
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<string, unknown> => JSON.parse(json);

describe('<UpdateGate>', () => {
it('does not render force modal or suggest banner when verdict is "none"', () => {
const { getByTestId } = render(
<UpdateGate
installed="1.7.0"
thresholds={{ force: '1.0.0', suggest: '1.7.0' }}
/>,
);
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(
<UpdateGate
installed="1.5.0"
thresholds={{ force: '1.6.0', suggest: '1.7.0' }}
/>,
);
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(
<UpdateGate
installed="1.6.0"
thresholds={{ force: '1.6.0', suggest: '1.7.0' }}
/>,
);
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(
<UpdateGate
installed="1.5.0"
thresholds={{ force: '1.6.0' }}
accent="#FF0000"
/>,
);
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(
<UpdateGate
installed="1.5.0"
thresholds={{ force: '1.6.0' }}
force={{ title: 'Critical', message: 'Update now', buttonText: 'GO' }}
/>,
);
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(
<UpdateGate
installed="1.6.0"
thresholds={{ force: '1.6.0', suggest: '1.7.0' }}
suggest={{ title: 'New version', position: 'bottom' }}
/>,
);
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(
<UpdateGate
installed="1.5.0"
thresholds={{ force: '1.6.0', suggest: '1.7.0' }}
/>,
);
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(
<UpdateGate
installed="1.5.0"
thresholds={{ force: '1.6.0' }}
/>,
);
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(
<UpdateGate
installed="1.5.0"
thresholds={{ force: '1.6.0' }}
showVersionDiff={false}
/>,
);
// 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(
<UpdateGate
installed="1.5.0"
thresholds={{ force: '1.6.0', suggest: '1.7.0' }}
force={{ versionLabel: 'Security patch' }}
/>,
);
expect(parseProps(getByTestId('force-modal').children.join('')).versionLabel).toBe(
'Security patch',
);
});
});
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
77 changes: 77 additions & 0 deletions src/components/UpdateGate.tsx
Original file line number Diff line number Diff line change
@@ -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<ForceUpdateModalProps, 'visible' | 'accent' | 'theme' | 'icon'>;
/** Suggest-banner customisation (title, message, position, etc.). */
suggest?: Omit<SuggestUpdateBannerProps, 'visible' | 'accent' | 'theme' | 'icon'>;
/** Auto-build `vX → vY` pill on the force modal. Default `true`. */
showVersionDiff?: boolean;
}

export const UpdateGate: React.FC<UpdateGateProps> = ({
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 (
<>
<ForceUpdateModal
visible={verdict === 'force'}
accent={accent}
theme={theme}
icon={icon}
versionLabel={force?.versionLabel ?? autoVersionLabel}
{...force}
/>
<SuggestUpdateBanner
visible={verdict === 'suggest'}
accent={accent}
theme={theme}
icon={icon}
{...suggest}
/>
</>
);
};
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ export {
SuggestUpdateBanner,
type SuggestUpdateBannerProps,
} from './components/SuggestUpdateBanner';
export {
UpdateGate,
type UpdateGateProps,
type UpdateThresholds,
} from './components/UpdateGate';
export {
UpdateGateThemeProvider,
lightTheme,
Expand Down
Loading