From 1f18ba014540ab601aa570c0b760a00907b9935c Mon Sep 17 00:00:00 2001 From: Shohruhmirzo Usmonov Date: Thu, 26 Mar 2026 17:38:04 +0500 Subject: [PATCH 1/2] feat(hooks): add useList hook with remove/removeAt --- package.json | 1 + packages/react/src/__tests__/useList-test.js | 114 +++++++++++++++++++ packages/react/src/useList.js | 24 ++++ yarn.lock | 21 ++++ 4 files changed, 160 insertions(+) create mode 100644 packages/react/src/__tests__/useList-test.js create mode 100644 packages/react/src/useList.js diff --git a/package.json b/package.json index a1f5aa0eeff9..1a6e98dee57c 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@rollup/plugin-node-resolve": "^15.0.1", "@rollup/plugin-replace": "^5.0.2", "@rollup/plugin-typescript": "^12.1.2", + "@testing-library/react-hooks": "^8.0.1", "@types/invariant": "^2.2.35", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", diff --git a/packages/react/src/__tests__/useList-test.js b/packages/react/src/__tests__/useList-test.js new file mode 100644 index 000000000000..c0b46171f72b --- /dev/null +++ b/packages/react/src/__tests__/useList-test.js @@ -0,0 +1,114 @@ +/** + * @jest-environment jsdom + */ + +'use strict'; + +let React; +let ReactDOMClient; +let act; +let useList; + +describe('useList', () => { + beforeEach(() => { + jest.resetModules(); + + React = require('react'); + ReactDOMClient = require('react-dom/client'); + act = require('internal-test-utils').act; + + useList = require('../useList').default; + }); + + function renderHook(hook) { + const result = { current: null }; + let rerender; + function TestComponent() { + const [, setTick] = React.useState(0); + rerender = () => setTick(t => t + 1); + result.current = hook(); + return null; + } + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + const {flushSync} = require('react-dom'); + flushSync(() => { + root.render(React.createElement(TestComponent)); + }); + return { result, rerender }; + } + + it('should initialize with provided list', () => { + const { result } = renderHook(() => useList([1, 2, 3])); + expect(result.current.list).toEqual([1, 2, 3]); + }); + + it('should initialize with empty list if none provided', () => { + const { result } = renderHook(() => useList()); + expect(result.current.list).toEqual([]); + }); + + it('should set list using setList', async () => { + const { result, rerender } = renderHook(() => useList([1, 2, 3])); + await act(() => { + result.current.setList([4, 5, 6]); + rerender(); + }); + expect(result.current.list).toEqual([4, 5, 6]); + }); + + it('should remove items using predicate', async () => { + const { result, rerender } = renderHook(() => useList([1, 2, 3, 4])); + expect(result.current).not.toBe(null); + await act(() => { + result.current.remove(x => x % 2 === 0); + rerender(); + }); + expect(result.current.list).toEqual([1, 3]); + }); + + it('should not remove any items if predicate matches none', async () => { + const { result, rerender } = renderHook(() => useList([1, 2, 3])); + await act(() => { + result.current.remove(x => x > 10); + rerender(); + }); + expect(result.current.list).toEqual([1, 2, 3]); + }); + + it('should remove all items if predicate matches all', async () => { + const { result, rerender } = renderHook(() => useList([1, 2, 3])); + await act(() => { + result.current.remove(() => true); + rerender(); + }); + expect(result.current.list).toEqual([]); + }); + + it('should remove item at given index', async () => { + const { result, rerender } = renderHook(() => useList(['a', 'b', 'c'])); + await act(() => { + result.current.removeAt(1); + rerender(); + }); + expect(result.current.list).toEqual(['a', 'c']); + }); + + it('should do nothing if removeAt is called with invalid index', async () => { + const { result, rerender } = renderHook(() => useList(['a', 'b', 'c'])); + await act(() => { + result.current.removeAt(10); + rerender(); + }); + expect(result.current.list).toEqual(['a', 'b', 'c']); + }); + + it('should handle removeAt on empty list', async () => { + const { result, rerender } = renderHook(() => useList([])); + await act(() => { + result.current.removeAt(0); + rerender(); + }); + expect(result.current.list).toEqual([]); + }); +}); diff --git a/packages/react/src/useList.js b/packages/react/src/useList.js new file mode 100644 index 000000000000..1a32e6d11456 --- /dev/null +++ b/packages/react/src/useList.js @@ -0,0 +1,24 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + */ + +import { useState, useCallback } from 'react'; + +export default function useList(initialList = []) { + const [list, setList] = useState(initialList); + + const remove = useCallback((predicate) => { + setList(prev => prev.filter(item => !predicate(item))); + }, []); + + const removeAt = useCallback((index) => { + setList(prev => prev.filter((_, i) => i !== index)); + }, []); + + return { list, setList, remove, removeAt }; +} diff --git a/yarn.lock b/yarn.lock index 303fed599cdc..dd976a9aa3e0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2442,6 +2442,11 @@ dependencies: regenerator-runtime "^0.13.4" +"@babel/runtime@^7.12.5": + version "7.29.2" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.29.2.tgz#9a6e2d05f4b6692e1801cd4fb176ad823930ed5e" + integrity sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g== + "@babel/template@^7.10.4": version "7.12.13" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.12.13.tgz#530265be8a2589dbb37523844c5bcb55947fb327" @@ -3809,6 +3814,14 @@ dependencies: defer-to-connect "^2.0.1" +"@testing-library/react-hooks@^8.0.1": + version "8.0.1" + resolved "https://registry.yarnpkg.com/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz#0924bbd5b55e0c0c0502d1754657ada66947ca12" + integrity sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g== + dependencies: + "@babel/runtime" "^7.12.5" + react-error-boundary "^3.1.0" + "@tokenizer/token@^0.1.1": version "0.1.1" resolved "https://registry.yarnpkg.com/@tokenizer/token/-/token-0.1.1.tgz#f0d92c12f87079ddfd1b29f614758b9696bc29e3" @@ -8167,6 +8180,7 @@ eslint-plugin-no-unsanitized@4.0.2: "eslint-plugin-react-internal@link:./scripts/eslint-rules": version "0.0.0" + uid "" eslint-plugin-react@^6.7.1: version "6.10.3" @@ -14633,6 +14647,13 @@ rc@1.2.8, rc@^1.2.8: object-assign "^4.1.1" scheduler "^0.20.2" +react-error-boundary@^3.1.0: + version "3.1.4" + resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.4.tgz#255db92b23197108757a888b01e5b729919abde0" + integrity sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA== + dependencies: + "@babel/runtime" "^7.12.5" + react-is@^16.8.1, react-is@^18.0.0, "react-is@npm:react-is": version "18.3.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" From 2151bf61f0f35c417ff3044bd90a6fd8b6ebb0b0 Mon Sep 17 00:00:00 2001 From: Shohruhmirzo Usmonov Date: Thu, 26 Mar 2026 17:44:22 +0500 Subject: [PATCH 2/2] style: format code for consistency in useList and useList-test --- packages/react/src/__tests__/useList-test.js | 22 ++++++++++---------- packages/react/src/useList.js | 8 +++---- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/react/src/__tests__/useList-test.js b/packages/react/src/__tests__/useList-test.js index c0b46171f72b..192f0fb11358 100644 --- a/packages/react/src/__tests__/useList-test.js +++ b/packages/react/src/__tests__/useList-test.js @@ -21,7 +21,7 @@ describe('useList', () => { }); function renderHook(hook) { - const result = { current: null }; + const result = {current: null}; let rerender; function TestComponent() { const [, setTick] = React.useState(0); @@ -35,21 +35,21 @@ describe('useList', () => { flushSync(() => { root.render(React.createElement(TestComponent)); }); - return { result, rerender }; + return {result, rerender}; } it('should initialize with provided list', () => { - const { result } = renderHook(() => useList([1, 2, 3])); + const {result} = renderHook(() => useList([1, 2, 3])); expect(result.current.list).toEqual([1, 2, 3]); }); it('should initialize with empty list if none provided', () => { - const { result } = renderHook(() => useList()); + const {result} = renderHook(() => useList()); expect(result.current.list).toEqual([]); }); it('should set list using setList', async () => { - const { result, rerender } = renderHook(() => useList([1, 2, 3])); + const {result, rerender} = renderHook(() => useList([1, 2, 3])); await act(() => { result.current.setList([4, 5, 6]); rerender(); @@ -58,7 +58,7 @@ describe('useList', () => { }); it('should remove items using predicate', async () => { - const { result, rerender } = renderHook(() => useList([1, 2, 3, 4])); + const {result, rerender} = renderHook(() => useList([1, 2, 3, 4])); expect(result.current).not.toBe(null); await act(() => { result.current.remove(x => x % 2 === 0); @@ -68,7 +68,7 @@ describe('useList', () => { }); it('should not remove any items if predicate matches none', async () => { - const { result, rerender } = renderHook(() => useList([1, 2, 3])); + const {result, rerender} = renderHook(() => useList([1, 2, 3])); await act(() => { result.current.remove(x => x > 10); rerender(); @@ -77,7 +77,7 @@ describe('useList', () => { }); it('should remove all items if predicate matches all', async () => { - const { result, rerender } = renderHook(() => useList([1, 2, 3])); + const {result, rerender} = renderHook(() => useList([1, 2, 3])); await act(() => { result.current.remove(() => true); rerender(); @@ -86,7 +86,7 @@ describe('useList', () => { }); it('should remove item at given index', async () => { - const { result, rerender } = renderHook(() => useList(['a', 'b', 'c'])); + const {result, rerender} = renderHook(() => useList(['a', 'b', 'c'])); await act(() => { result.current.removeAt(1); rerender(); @@ -95,7 +95,7 @@ describe('useList', () => { }); it('should do nothing if removeAt is called with invalid index', async () => { - const { result, rerender } = renderHook(() => useList(['a', 'b', 'c'])); + const {result, rerender} = renderHook(() => useList(['a', 'b', 'c'])); await act(() => { result.current.removeAt(10); rerender(); @@ -104,7 +104,7 @@ describe('useList', () => { }); it('should handle removeAt on empty list', async () => { - const { result, rerender } = renderHook(() => useList([])); + const {result, rerender} = renderHook(() => useList([])); await act(() => { result.current.removeAt(0); rerender(); diff --git a/packages/react/src/useList.js b/packages/react/src/useList.js index 1a32e6d11456..41b853821470 100644 --- a/packages/react/src/useList.js +++ b/packages/react/src/useList.js @@ -7,18 +7,18 @@ * @emails react-core */ -import { useState, useCallback } from 'react'; +import {useState, useCallback} from 'react'; export default function useList(initialList = []) { const [list, setList] = useState(initialList); - const remove = useCallback((predicate) => { + const remove = useCallback(predicate => { setList(prev => prev.filter(item => !predicate(item))); }, []); - const removeAt = useCallback((index) => { + const removeAt = useCallback(index => { setList(prev => prev.filter((_, i) => i !== index)); }, []); - return { list, setList, remove, removeAt }; + return {list, setList, remove, removeAt}; }