From dcc5da9f71c7bf1872ca769c488b3b899417ef5f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tr=E1=BA=A7n=20=C4=90=C3=ACnh=20Huy?=
<68500260+huytdps13400@users.noreply.github.com>
Date: Mon, 5 Oct 2026 02:10:49 +0700
Subject: [PATCH] fix: skip callbacks for the selected segment
---
.github/workflows/ci.yml | 2 +
js/SegmentedControl.js | 4 ++
js/__tests__/SegmentedControl.test.js | 99 +++++++++++++++++++++++++++
3 files changed, 105 insertions(+)
create mode 100644 js/__tests__/SegmentedControl.test.js
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 155884b..e8ab399 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -31,6 +31,8 @@ jobs:
run: pnpm install
- name: ESLint Checks
run: pnpm lint
+ - name: Jest Tests
+ run: pnpm exec jest --runInBand
flow:
runs-on: ubuntu-latest
strategy:
diff --git a/js/SegmentedControl.js b/js/SegmentedControl.js
index 952d0fc..1c1c95a 100644
--- a/js/SegmentedControl.js
+++ b/js/SegmentedControl.js
@@ -47,6 +47,10 @@ const SegmentedControl = ({
const ref = React.useRef>(null);
const handleChange = (index: number) => {
+ if (index === selectedIndex) {
+ return;
+ }
+
// mocks iOS's nativeEvent
const event: any = {
nativeEvent: {
diff --git a/js/__tests__/SegmentedControl.test.js b/js/__tests__/SegmentedControl.test.js
new file mode 100644
index 0000000..71a06ce
--- /dev/null
+++ b/js/__tests__/SegmentedControl.test.js
@@ -0,0 +1,99 @@
+/* eslint-env jest */
+
+import React from 'react';
+import {TouchableOpacity} from 'react-native';
+import renderer, {act} from 'react-test-renderer';
+import SegmentedControl from '../SegmentedControl.js';
+
+describe('Android and web selection callbacks', () => {
+ let tree;
+ let props;
+
+ beforeEach(() => {
+ props = {
+ values: ['One', 'Two'],
+ selectedIndex: 0,
+ onChange: jest.fn(),
+ onValueChange: jest.fn(),
+ };
+ });
+
+ afterEach(() => {
+ act(() => tree?.unmount());
+ });
+
+ function render(overrides = {}) {
+ props = {...props, ...overrides};
+ act(() => {
+ tree = renderer.create();
+ });
+ }
+
+ function press(index) {
+ act(() => {
+ tree.root.findAllByType(TouchableOpacity)[index].props.onPress();
+ });
+ }
+
+ it.each([0, 1])('does not notify for selected index %i', (selectedIndex) => {
+ render({selectedIndex});
+ press(selectedIndex);
+ expect(props.onChange).not.toHaveBeenCalled();
+ expect(props.onValueChange).not.toHaveBeenCalled();
+ });
+
+ it('notifies both callbacks once with the newly selected segment', () => {
+ render();
+ press(1);
+ expect(props.onChange).toHaveBeenCalledTimes(1);
+ expect(props.onChange).toHaveBeenCalledWith({
+ nativeEvent: {value: 'Two', selectedSegmentIndex: 1},
+ });
+ expect(props.onValueChange).toHaveBeenCalledTimes(1);
+ expect(props.onValueChange).toHaveBeenCalledWith('Two');
+ });
+
+ it('uses the latest controlled selection after a prop update', () => {
+ render();
+ act(() => {
+ tree.update();
+ });
+ press(1);
+ expect(props.onChange).not.toHaveBeenCalled();
+ expect(props.onValueChange).not.toHaveBeenCalled();
+ press(0);
+ expect(props.onChange).toHaveBeenCalledTimes(1);
+ expect(props.onChange).toHaveBeenCalledWith({
+ nativeEvent: {value: 'One', selectedSegmentIndex: 0},
+ });
+ expect(props.onValueChange).toHaveBeenCalledTimes(1);
+ expect(props.onValueChange).toHaveBeenCalledWith('One');
+ });
+
+ it('compares indexes when two segments have the same value', () => {
+ render({values: ['Same', 'Same']});
+ press(1);
+ expect(props.onChange).toHaveBeenCalledWith({
+ nativeEvent: {value: 'Same', selectedSegmentIndex: 1},
+ });
+ expect(props.onValueChange).toHaveBeenCalledWith('Same');
+ });
+
+ it.each([undefined, null, -1])(
+ 'allows a first selection when selectedIndex is %s',
+ (selectedIndex) => {
+ render({selectedIndex});
+ press(0);
+ expect(props.onChange).toHaveBeenCalledTimes(1);
+ expect(props.onValueChange).toHaveBeenCalledWith('One');
+ },
+ );
+
+ it('allows optional callbacks to be omitted', () => {
+ render({onChange: undefined, onValueChange: undefined});
+ expect(() => {
+ press(0);
+ press(1);
+ }).not.toThrow();
+ });
+});