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..192f0fb11358 --- /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..41b853821470 --- /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"