From a2dd7a19ad2e970842f1c9d005247ca10981ce09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20B=C3=A1rta?= Date: Wed, 18 Mar 2026 16:31:30 +0100 Subject: [PATCH] Rewrite state management with pure tree operations Replace mutation-based tree operations with immutable pure functions using structural sharing. Remove lodash dependency. - 12-32x faster updates (no more cloneDeep on every change) - 11-13x faster selection - Reorganize src/ into components/, tree/, hooks/, types.ts --- .gitignore | 1 + package-lock.json | 18 +- package.json | 4 +- src/ReactTreeList.tsx | 344 ------------------ src/components/ReactTreeList.tsx | 206 +++++++++++ src/{ => components}/ReactTreeListItem.tsx | 40 +- src/hooks/{useUniqueId.tsx => useUniqueId.ts} | 0 src/index.ts | 2 + src/index.tsx | 2 - src/tree/assignIds.ts | 38 ++ src/tree/find.ts | 18 + src/tree/index.ts | 9 + src/tree/move.ts | 133 +++++++ src/tree/update.ts | 51 +++ src/{types/ItemTypes.tsx => types.ts} | 0 src/utils/useGetItemById.tsx | 21 -- src/utils/useUpdateItemById.tsx | 33 -- src/utils/useUpdateSelectedItemById.tsx | 28 -- test/tree.bench.ts | 88 +++++ test/useGetItemById.test.tsx | 13 +- tsconfig.json | 6 +- vite.config.ts | 4 +- 22 files changed, 584 insertions(+), 475 deletions(-) delete mode 100644 src/ReactTreeList.tsx create mode 100644 src/components/ReactTreeList.tsx rename src/{ => components}/ReactTreeListItem.tsx (90%) rename src/hooks/{useUniqueId.tsx => useUniqueId.ts} (100%) create mode 100644 src/index.ts delete mode 100644 src/index.tsx create mode 100644 src/tree/assignIds.ts create mode 100644 src/tree/find.ts create mode 100644 src/tree/index.ts create mode 100644 src/tree/move.ts create mode 100644 src/tree/update.ts rename src/{types/ItemTypes.tsx => types.ts} (100%) delete mode 100644 src/utils/useGetItemById.tsx delete mode 100644 src/utils/useUpdateItemById.tsx delete mode 100644 src/utils/useUpdateSelectedItemById.tsx create mode 100644 test/tree.bench.ts diff --git a/.gitignore b/.gitignore index 2cd0d8a..8a25549 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ coverage /.idea *.lock /dist +bench-baseline.json diff --git a/package-lock.json b/package-lock.json index d305c1c..0e9a85d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "version": "0.6.0", "license": "MIT", "dependencies": { - "lodash": "^4.17.23", "styled-components": "^6.0.0" }, "devDependencies": { @@ -19,7 +18,6 @@ "@storybook/test": "^8.6.18", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.1.0", - "@types/lodash": "^4.14.182", "@types/node": "^22.0.0", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", @@ -3109,12 +3107,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/lodash": { - "version": "4.14.182", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.182.tgz", - "integrity": "sha512-/THyiqyQAP9AfARo4pF+aCGcyiQ94tX/Is2I7HofNRqoYLgN1PBoOWu2/zTA5zMxzP5EFutMtWtGAFRKUe961Q==", - "dev": true - }, "node_modules/@types/mdx": { "version": "2.0.13", "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", @@ -5135,6 +5127,7 @@ "version": "4.17.23", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "dev": true, "license": "MIT" }, "node_modules/loose-envify": { @@ -9056,12 +9049,6 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true }, - "@types/lodash": { - "version": "4.14.182", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.182.tgz", - "integrity": "sha512-/THyiqyQAP9AfARo4pF+aCGcyiQ94tX/Is2I7HofNRqoYLgN1PBoOWu2/zTA5zMxzP5EFutMtWtGAFRKUe961Q==", - "dev": true - }, "@types/mdx": { "version": "2.0.13", "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", @@ -10465,7 +10452,8 @@ "lodash": { "version": "4.17.23", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==" + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "dev": true }, "loose-envify": { "version": "1.4.0", diff --git a/package.json b/package.json index 09ca4ec..ec3102c 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "scripts": { "test": "vitest", "test:coverage": "vitest run --coverage", + "bench": "vitest bench", + "bench:save": "vitest bench --outputJson bench-baseline.json", "lint": "oxlint src", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build -o .out", @@ -45,7 +47,6 @@ "@storybook/test": "^8.6.18", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.1.0", - "@types/lodash": "^4.14.182", "@types/node": "^22.0.0", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", @@ -72,7 +73,6 @@ "pre-commit": "npx pretty-quick --staged" }, "dependencies": { - "lodash": "^4.17.23", "styled-components": "^6.0.0" } } diff --git a/src/ReactTreeList.tsx b/src/ReactTreeList.tsx deleted file mode 100644 index ec0635a..0000000 --- a/src/ReactTreeList.tsx +++ /dev/null @@ -1,344 +0,0 @@ -import React, { useEffect, useRef, useState } from "react"; -import styled from "styled-components"; -import { cloneDeep } from "lodash"; -import { ItemOptions, ReactTreeListItem } from "./ReactTreeListItem"; -import { useUniqueId } from "./hooks/useUniqueId"; -import { ReactTreeListItemType } from "./types/ItemTypes"; -import { useGetItemById } from "./utils/useGetItemById"; -import { useUpdateItemById } from "./utils/useUpdateItemById"; -import { useUpdateSelectedItemById } from "./utils/useUpdateSelectedItemById"; - -export interface ReactTreeListProps { - /** - * The data to display in the list. - */ - data: ReactTreeListItemType[]; - - /** - * Enable dragging of the items - */ - draggable?: boolean | true; - - /** - * Function that is triggered when data changes - */ - onChange(data: ReactTreeListItemType[]): void; - - /** - * The ID of the selected item - */ - selectedId?: string | ""; - - /** - * Function that is triggered when an item is selected - */ - onSelected?(item: ReactTreeListItemType): void; - - /** - * Function that is triggered when drag completed - */ - onDrop?( - draggingNode: ReactTreeListItemType, - dropNode: ReactTreeListItemType, - dropType: string - ): void; - - /** - * Defines the default values for item object - * - * Eg. `itemDefaults={{ open: true }}` will make all items open by default unless specified otherwise - * inside each item separately. - */ - itemDefaults?: Partial>; - - /** - * Options for the items - */ - itemOptions?: ItemOptions; -} - -export const ReactTreeList: React.FC = ({ - draggable, - data, - selectedId, - onChange, - onSelected, - onDrop, - itemDefaults, - itemOptions = {}, -}) => { - const { generate: generateUniqueId } = useUniqueId(); - const lastOpenState = useRef(false); - - const getItemById = useGetItemById(data); - const updateItemById = useUpdateItemById( - data, - onChange - ); - - const [currentSelectedId, setCurrentSelectedId] = useState(selectedId); - - const updateSelectedItemById = - useUpdateSelectedItemById(data, onChange); - - /** - * To make sure the event runs only once, we store in this variable - * whether the event should run. - */ - let triggerOnChange = false; - - let selectedOnChange = false; - - const removeByIdWithoutOnChange = ( - id: string - ): ReactTreeListItemType | undefined => { - let returnItem: ReactTreeListItemType | undefined = undefined; - - const recursiveRemoveId = ( - item: ReactTreeListItemType, - index: number, - array: ReactTreeListItemType[] - ) => { - if (returnItem) return; - - if (item.id === id) { - returnItem = item; - array.splice(index, 1); - return; - } - - if (item.children) { - item.children.forEach(recursiveRemoveId); - } - }; - - data.forEach(recursiveRemoveId); - - return returnItem; - }; - - const selectedNode = (item: ReactTreeListItemType) => { - selectedOnChange = true; - setCurrentSelectedId(item.id || ""); - - if (onSelected) { - onSelected(cloneDeep(item)); - } - }; - - const moveIdTo = (id: string, toId: string) => { - const copyOfItem = removeByIdWithoutOnChange(id); - if (!copyOfItem) return; - - const item = getItemById(toId); - - if (item) { - item.open = true; - - if (!item.children) { - item.children = [copyOfItem]; - } else { - item.children.unshift(copyOfItem); - } - - triggerOnChange = true; - - if (onDrop && item) { - const dragingNode = cloneDeep(copyOfItem); - if ("children" in dragingNode) { - delete dragingNode.children; - } - - const dragNode = cloneDeep(item); - if ("children" in dragNode) { - delete dragNode.children; - } - - onDrop(dragingNode, dragNode, "inner"); - } - } - }; - - const moveIdBefore = (id: string, beforeId: string) => { - const copyOfItem = removeByIdWithoutOnChange(id); - let dragNode: ReactTreeListItemType | null = null; - let breakRecursion = false; - - if (!copyOfItem) return; - - const recursiveMoveIdAfter = ( - item: ReactTreeListItemType, - index: number, - array: ReactTreeListItemType[] - ) => { - if (breakRecursion) return; - - if (item.id === beforeId) { - array.splice(index, 0, copyOfItem); - breakRecursion = true; - dragNode = cloneDeep(item); - } else if (item.children) { - item.children.forEach(recursiveMoveIdAfter); - } - }; - - data.forEach(recursiveMoveIdAfter); - - triggerOnChange = true; - - if (onDrop && dragNode) { - const dragingNode = cloneDeep(copyOfItem); - if ("children" in dragingNode) { - delete dragingNode.children; - } - - if ("children" in dragNode) { - delete (dragNode as ReactTreeListItemType).children; - } - - onDrop(dragingNode, dragNode, "before"); - } - }; - - const moveIdAfter = (id: string, afterId: string) => { - const copyOfItem = removeByIdWithoutOnChange(id); - let dragNode = null; - let breakRecursion = false; - - if (!copyOfItem) return; - - const recursiveMoveIdAfter = ( - item: ReactTreeListItemType, - index: number, - array: ReactTreeListItemType[] - ) => { - if (breakRecursion) return; - - if (item.id === afterId) { - array.splice(index + 1, 0, copyOfItem); - breakRecursion = true; - dragNode = cloneDeep(item); - } else if (item.children) { - item.children.forEach(recursiveMoveIdAfter); - } - }; - - data.forEach(recursiveMoveIdAfter); - - triggerOnChange = true; - - if (onDrop && dragNode) { - const dragingNode = cloneDeep(copyOfItem); - if ("children" in dragingNode) { - delete dragingNode.children; - } - - if ("children" in dragNode) { - delete (dragNode as ReactTreeListItemType).children; - } - - onDrop(dragingNode, dragNode, "after"); - } - }; - - const renderContent = () => { - /** - * The children will be rendered as flat, contrary to the tree - * structure of data. - */ - const children: React.ReactNode[] = []; - /** - * A counter for the indentation of items - */ - let indent = 0; - const renderItem = ( - listItem: ReactTreeListItemType, - index: number, - array: ReactTreeListItemType[], - parentOpen?: boolean, - isFirstLoop?: boolean - ) => { - const isFirstItemInFirstLoop = isFirstLoop && index === 0; - - if (!listItem.id) { - triggerOnChange = true; - array[index].id = generateUniqueId(); - } - - const item = array[index]; - - if (parentOpen) { - children.push( - { - updateSelectedItemById(item.id); - selectedNode(item); - }} - onArrowClick={() => updateItemById(item.id, { open: !item.open })} - onDragging={(drag) => { - if (drag) { - lastOpenState.current = !!item.open; - updateItemById(item.id, { open: false }); - } else { - updateItemById(item.id, { open: lastOpenState.current }); - } - }} - onDropInside={(id, toId) => moveIdTo(id, toId)} - onDropBefore={(id, beforeId) => moveIdBefore(id, beforeId)} - onDropAfter={(id, afterId) => moveIdAfter(id, afterId)} - /> - ); - } - - if (item.children) { - // Indent up before processing children - indent += 1; - - item.children.forEach((nestedListItem, nestedIndex, nestedArray) => - renderItem( - nestedListItem, - nestedIndex, - nestedArray, - parentOpen ? item.open : false - ) - ); - - // Indent down after children processed - indent -= 1; - } - }; - - data.forEach((listItem, index, array) => - renderItem(listItem, index, array, true, true) - ); - - return children; - }; - - useEffect(() => { - if (selectedId) { - const selectedItem = getItemById(selectedId); - if (selectedItem) { - updateSelectedItemById(selectedItem.id); - selectedNode(selectedItem); - } - } - }, [selectedId]); - - useEffect(() => { - if (triggerOnChange) { - onChange(cloneDeep(data)); - } - }, [triggerOnChange]); - - return {renderContent()}; -}; - -const Root = styled.div``; diff --git a/src/components/ReactTreeList.tsx b/src/components/ReactTreeList.tsx new file mode 100644 index 0000000..72af0cd --- /dev/null +++ b/src/components/ReactTreeList.tsx @@ -0,0 +1,206 @@ +import React, { useEffect, useRef, useState } from "react"; +import styled from "styled-components"; +import { ItemOptions, ReactTreeListItem } from "./ReactTreeListItem"; +import { useUniqueId } from "../hooks/useUniqueId"; +import { ReactTreeListItemType } from "../types"; +import { + assignMissingIds, + moveItemAfter, + moveItemBefore, + moveItemInside, + setSelectedById, + updateItemById, +} from "../tree"; + +export interface ReactTreeListProps { + /** + * The data to display in the list. + */ + data: ReactTreeListItemType[]; + + /** + * Enable dragging of the items + */ + draggable?: boolean | true; + + /** + * Function that is triggered when data changes + */ + onChange(data: ReactTreeListItemType[]): void; + + /** + * The ID of the selected item + */ + selectedId?: string | ""; + + /** + * Function that is triggered when an item is selected + */ + onSelected?(item: ReactTreeListItemType): void; + + /** + * Function that is triggered when drag completed + */ + onDrop?( + draggingNode: ReactTreeListItemType, + dropNode: ReactTreeListItemType, + dropType: string + ): void; + + /** + * Defines the default values for item object + * + * Eg. `itemDefaults={{ open: true }}` will make all items open by default unless specified otherwise + * inside each item separately. + */ + itemDefaults?: Partial>; + + /** + * Options for the items + */ + itemOptions?: ItemOptions; +} + +export const ReactTreeList: React.FC = ({ + draggable, + data, + selectedId, + onChange, + onSelected, + onDrop, + itemDefaults, + itemOptions = {}, +}) => { + const { generate: generateUniqueId } = useUniqueId(); + const lastOpenState = useRef(false); + const [currentSelectedId, setCurrentSelectedId] = useState(selectedId); + + // Assign missing IDs when data changes + useEffect(() => { + const [newData, changed] = assignMissingIds(data, generateUniqueId); + if (changed) onChange(newData); + }, [data]); + + // Sync external selectedId prop + useEffect(() => { + if (selectedId !== undefined) { + setCurrentSelectedId(selectedId); + } + }, [selectedId]); + + const stripChildren = ( + item: ReactTreeListItemType + ): ReactTreeListItemType => { + const { children, ...rest } = item; + return rest; + }; + + const handleSelect = (item: ReactTreeListItemType) => { + setCurrentSelectedId(item.id ?? ""); + onChange(setSelectedById(data, item.id!)); + onSelected?.({ ...item }); + }; + + const handleMoveInside = (id: string, toId: string) => { + const [newData, draggedItem, dropTarget] = moveItemInside(data, id, toId); + if (!draggedItem) return; + onChange(newData); + if (onDrop && dropTarget) { + onDrop(stripChildren(draggedItem), stripChildren(dropTarget), "inner"); + } + }; + + const handleMoveBefore = (id: string, beforeId: string) => { + const [newData, draggedItem, dropTarget] = moveItemBefore( + data, + id, + beforeId + ); + if (!draggedItem) return; + onChange(newData); + if (onDrop && dropTarget) { + onDrop(stripChildren(draggedItem), stripChildren(dropTarget), "before"); + } + }; + + const handleMoveAfter = (id: string, afterId: string) => { + const [newData, draggedItem, dropTarget] = moveItemAfter(data, id, afterId); + if (!draggedItem) return; + onChange(newData); + if (onDrop && dropTarget) { + onDrop(stripChildren(draggedItem), stripChildren(dropTarget), "after"); + } + }; + + const renderContent = () => { + const children: React.ReactNode[] = []; + let indent = 0; + + const renderItem = ( + listItem: ReactTreeListItemType, + index: number, + array: ReactTreeListItemType[], + parentOpen?: boolean, + isFirstLoop?: boolean + ) => { + const isFirstItemInFirstLoop = isFirstLoop && index === 0; + const item = array[index]; + + if (parentOpen) { + children.push( + handleSelect(item)} + onArrowClick={() => + onChange(updateItemById(data, item.id!, { open: !item.open })) + } + onDragging={(drag) => { + if (drag) { + lastOpenState.current = !!item.open; + onChange(updateItemById(data, item.id!, { open: false })); + } else { + onChange( + updateItemById(data, item.id!, { + open: lastOpenState.current, + }) + ); + } + }} + onDropInside={handleMoveInside} + onDropBefore={handleMoveBefore} + onDropAfter={handleMoveAfter} + /> + ); + } + + if (item.children) { + indent += 1; + item.children.forEach((nestedListItem, nestedIndex, nestedArray) => + renderItem( + nestedListItem, + nestedIndex, + nestedArray, + parentOpen ? item.open : false + ) + ); + indent -= 1; + } + }; + + data.forEach((listItem, index, array) => + renderItem(listItem, index, array, true, true) + ); + + return children; + }; + + return {renderContent()}; +}; + +const Root = styled.div``; diff --git a/src/ReactTreeListItem.tsx b/src/components/ReactTreeListItem.tsx similarity index 90% rename from src/ReactTreeListItem.tsx rename to src/components/ReactTreeListItem.tsx index ead68eb..947de6d 100644 --- a/src/ReactTreeListItem.tsx +++ b/src/components/ReactTreeListItem.tsx @@ -1,6 +1,14 @@ -import React, { useState, useEffect, useRef } from "react"; +import React, { + useState, + useEffect, + useRef, + HTMLAttributes, + RefObject, + forwardRef, + FC, +} from "react"; import styled from "styled-components"; -import { ReactTreeListItemType } from "./types/ItemTypes"; +import { ReactTreeListItemType } from "../types"; export interface ItemOptions { /** @@ -42,7 +50,7 @@ export interface ReactTreeListItemProps { options: ItemOptions; } -export const ReactTreeListItem: React.FC = ({ +export const ReactTreeListItem: FC = ({ onDragging, onDropInside, onDropBefore, @@ -84,11 +92,11 @@ export const ReactTreeListItem: React.FC = ({ } }; - const onDrag: React.HTMLAttributes["onDrag"] = () => { + const onDrag: HTMLAttributes["onDrag"] = () => { // setIsDragged(true); }; - const onDragStart: React.HTMLAttributes["onDragStart"] = ( + const onDragStart: HTMLAttributes["onDragStart"] = ( event ) => { onDragging && onDragging(true); @@ -98,11 +106,11 @@ export const ReactTreeListItem: React.FC = ({ } }; - const onDragEnd: React.HTMLAttributes["onDragEnd"] = () => { + const onDragEnd: HTMLAttributes["onDragEnd"] = () => { onDragging && onDragging(false); }; - const onFocusKeyPress: React.HTMLAttributes["onKeyPress"] = ( + const onFocusKeyPress: HTMLAttributes["onKeyPress"] = ( event ) => { if (event.which === 13) { @@ -131,7 +139,7 @@ export const ReactTreeListItem: React.FC = ({ }; }, []); - const dropArea: React.HTMLAttributes = { + const dropArea: HTMLAttributes = { onDrop: (event) => { if ( event.dataTransfer.getData("itemId") !== item.id && @@ -148,7 +156,7 @@ export const ReactTreeListItem: React.FC = ({ onDragLeave: () => setDragOver(false), }; - const beforeDropArea: React.HTMLAttributes = { + const beforeDropArea: HTMLAttributes = { onDrop: (event) => { if ( event.dataTransfer.getData("itemId") !== item.id && @@ -165,7 +173,7 @@ export const ReactTreeListItem: React.FC = ({ onDragLeave: () => setBeforeDropAreaDragOver(false), }; - const afterDropArea: React.HTMLAttributes = { + const afterDropArea: HTMLAttributes = { onDrop: (event) => { if ( item.children && @@ -190,7 +198,7 @@ export const ReactTreeListItem: React.FC = ({ onDragLeave: () => setAfterDropAreaDragOver(false), }; - const onClick: React.HTMLAttributes["onClick"] = (event) => { + const onClick: HTMLAttributes["onClick"] = (event) => { onSelected(); }; @@ -222,10 +230,10 @@ export const ReactTreeListItem: React.FC = ({ {allowDropBefore && ( - + <> - + )} @@ -235,11 +243,11 @@ export const ReactTreeListItem: React.FC = ({ ); }; -const RootComponent = React.forwardRef< +const RootComponent = forwardRef< HTMLDivElement, ReactTreeListItemProps & - React.HTMLAttributes & { - ref?: React.RefObject; + HTMLAttributes & { + ref?: RefObject; dragging?: boolean; isDragged?: boolean; } diff --git a/src/hooks/useUniqueId.tsx b/src/hooks/useUniqueId.ts similarity index 100% rename from src/hooks/useUniqueId.tsx rename to src/hooks/useUniqueId.ts diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..9233b4e --- /dev/null +++ b/src/index.ts @@ -0,0 +1,2 @@ +export * from "./types"; +export * from "./components/ReactTreeList"; diff --git a/src/index.tsx b/src/index.tsx deleted file mode 100644 index 5787785..0000000 --- a/src/index.tsx +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./types/ItemTypes"; -export * from "./ReactTreeList"; diff --git a/src/tree/assignIds.ts b/src/tree/assignIds.ts new file mode 100644 index 0000000..4966f98 --- /dev/null +++ b/src/tree/assignIds.ts @@ -0,0 +1,38 @@ +import { BaseItemType } from "../types"; + +/** + * Assign IDs to any items missing them. + * Returns [newData, changed]. Uses structural sharing — returns the + * same array/item references when nothing in a subtree changed. + */ +export const assignMissingIds = ( + data: T[], + generate: () => string +): [T[], boolean] => { + let changed = false; + + const recurse = (items: T[]): T[] => { + let anyItemChanged = false; + const result = items.map((item) => { + const needsId = !item.id; + const newChildren = item.children + ? recurse(item.children as T[]) + : item.children; + + if (needsId || newChildren !== item.children) { + anyItemChanged = true; + changed = true; + return { + ...item, + ...(needsId ? { id: generate() } : {}), + ...(newChildren !== item.children ? { children: newChildren } : {}), + } as T; + } + return item; + }); + return anyItemChanged ? result : items; + }; + + const result = recurse(data); + return [result, changed]; +}; diff --git a/src/tree/find.ts b/src/tree/find.ts new file mode 100644 index 0000000..b428540 --- /dev/null +++ b/src/tree/find.ts @@ -0,0 +1,18 @@ +import { BaseItemType } from "../types"; + +/** + * Find an item by ID with early exit. + */ +export const getItemById = ( + data: T[], + id: string +): T | undefined => { + for (const item of data) { + if (item.id === id) return item; + if (item.children) { + const found = getItemById(item.children as T[], id); + if (found) return found; + } + } + return undefined; +}; diff --git a/src/tree/index.ts b/src/tree/index.ts new file mode 100644 index 0000000..7b2984a --- /dev/null +++ b/src/tree/index.ts @@ -0,0 +1,9 @@ +export { getItemById } from "./find"; +export { updateItemById, setSelectedById } from "./update"; +export { + removeById, + moveItemInside, + moveItemBefore, + moveItemAfter, +} from "./move"; +export { assignMissingIds } from "./assignIds"; diff --git a/src/tree/move.ts b/src/tree/move.ts new file mode 100644 index 0000000..2ff6634 --- /dev/null +++ b/src/tree/move.ts @@ -0,0 +1,133 @@ +import { BaseItemType } from "../types"; + +/** + * Remove an item by ID. Returns [newData, removedItem]. + * Immutable — never mutates the input. + */ +export const removeById = ( + data: T[], + id: string +): [T[], T | undefined] => { + let removed: T | undefined; + + const recurse = (items: T[]): T[] => { + const result: T[] = []; + for (const item of items) { + if (item.id === id) { + removed = item; + continue; + } + if (item.children && !removed) { + const newChildren = recurse(item.children as T[]); + if (removed) { + result.push({ ...item, children: newChildren } as T); + continue; + } + } + result.push(item); + } + return result; + }; + + const newData = recurse(data); + return [newData, removed]; +}; + +/** + * Move item `id` inside `toId` (as first child), opening the target. + * Returns [newData, draggedItem, dropTarget]. + */ +export const moveItemInside = ( + data: T[], + id: string, + toId: string +): [T[], T | undefined, T | undefined] => { + const [withoutItem, removedItem] = removeById(data, id); + if (!removedItem) return [data, undefined, undefined]; + + let dropTarget: T | undefined; + + const insert = (items: T[]): T[] => + items.map((item) => { + if (item.id === toId) { + dropTarget = item; + return { + ...item, + open: true, + children: [removedItem, ...((item.children as T[]) ?? [])], + } as T; + } + if (item.children) { + const newChildren = insert(item.children as T[]); + if (newChildren !== item.children) + return { ...item, children: newChildren } as T; + } + return item; + }); + + return [insert(withoutItem), removedItem, dropTarget]; +}; + +/** + * Move item `id` before `beforeId`. + * Returns [newData, draggedItem, dropTarget]. + */ +export const moveItemBefore = ( + data: T[], + id: string, + beforeId: string +): [T[], T | undefined, T | undefined] => { + const [withoutItem, removedItem] = removeById(data, id); + if (!removedItem) return [data, undefined, undefined]; + + let dropTarget: T | undefined; + + const insert = (items: T[]): T[] => + items.flatMap((item) => { + if (item.id === beforeId) { + dropTarget = item; + return [removedItem, item]; + } + if (item.children && !dropTarget) { + const newChildren = insert(item.children as T[]); + if (newChildren !== item.children) { + return [{ ...item, children: newChildren } as T]; + } + } + return [item]; + }); + + return [insert(withoutItem), removedItem, dropTarget]; +}; + +/** + * Move item `id` after `afterId`. + * Returns [newData, draggedItem, dropTarget]. + */ +export const moveItemAfter = ( + data: T[], + id: string, + afterId: string +): [T[], T | undefined, T | undefined] => { + const [withoutItem, removedItem] = removeById(data, id); + if (!removedItem) return [data, undefined, undefined]; + + let dropTarget: T | undefined; + + const insert = (items: T[]): T[] => + items.flatMap((item) => { + if (item.id === afterId) { + dropTarget = item; + return [item, removedItem]; + } + if (item.children && !dropTarget) { + const newChildren = insert(item.children as T[]); + if (newChildren !== item.children) { + return [{ ...item, children: newChildren } as T]; + } + } + return [item]; + }); + + return [insert(withoutItem), removedItem, dropTarget]; +}; diff --git a/src/tree/update.ts b/src/tree/update.ts new file mode 100644 index 0000000..146bb71 --- /dev/null +++ b/src/tree/update.ts @@ -0,0 +1,51 @@ +import { BaseItemType } from "../types"; + +/** + * Return a new tree with the item at `id` updated with `updates`. + * Uses structural sharing — only clones nodes on the path to the target. + */ +export const updateItemById = ( + data: T[], + id: string, + updates: Partial +): T[] => { + return data.map((item) => { + if (item.id === id) { + return { ...item, ...updates }; + } + if (item.children) { + const newChildren = updateItemById(item.children as T[], id, updates); + if (newChildren !== item.children) { + return { ...item, children: newChildren }; + } + } + return item; + }); +}; + +/** + * Return a new tree with `selected: true` on the item matching `id` + * and `selected: false` on all others. + */ +export const setSelectedById = < + T extends BaseItemType & { selected?: boolean } +>( + data: T[], + id: string +): T[] => { + return data.map((item) => { + const selected = item.id === id; + const newChildren = item.children + ? setSelectedById(item.children as T[], id) + : undefined; + + if (item.selected !== selected || newChildren !== item.children) { + return { + ...item, + selected, + ...(newChildren !== undefined ? { children: newChildren } : {}), + }; + } + return item; + }); +}; diff --git a/src/types/ItemTypes.tsx b/src/types.ts similarity index 100% rename from src/types/ItemTypes.tsx rename to src/types.ts diff --git a/src/utils/useGetItemById.tsx b/src/utils/useGetItemById.tsx deleted file mode 100644 index 1917686..0000000 --- a/src/utils/useGetItemById.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { BaseItemType } from "../types/ItemTypes"; - -export const useGetItemById = (data: T[]) => { - return (id: string): T | undefined => { - let item: T | undefined; - - const recursiveGetById = (currentItem: T) => { - if (currentItem.id === id) { - item = currentItem; - } - - if (currentItem.children) { - (currentItem.children as T[]).forEach(recursiveGetById); - } - }; - - data.forEach(recursiveGetById); - - return item; - }; -}; diff --git a/src/utils/useUpdateItemById.tsx b/src/utils/useUpdateItemById.tsx deleted file mode 100644 index fdba6e3..0000000 --- a/src/utils/useUpdateItemById.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { cloneDeep } from "lodash"; -import { BaseItemType } from "../types/ItemTypes"; - -export const useUpdateItemById = ( - data: T[], - callback: (data: T[]) => void -) => { - return (updateId: string | undefined, updateData: Partial) => { - if (!updateId) { - return; - } - - let breakUpdateId = false; - - const recursiveUpdateId = (item: T, index: number, array: T[]) => { - if (breakUpdateId) return; - - if (item.id === updateId) { - array[index] = { ...item, ...updateData }; - breakUpdateId = true; - return; - } - - if (item.children) { - (item.children as T[]).forEach(recursiveUpdateId); - } - }; - - data.forEach(recursiveUpdateId); - - callback(cloneDeep(data)); - }; -}; diff --git a/src/utils/useUpdateSelectedItemById.tsx b/src/utils/useUpdateSelectedItemById.tsx deleted file mode 100644 index 65e1c39..0000000 --- a/src/utils/useUpdateSelectedItemById.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { cloneDeep } from "lodash"; -import { BaseItemType } from "../types/ItemTypes"; - -export const useUpdateSelectedItemById = ( - data: T[], - callback: (data: T[]) => void -) => { - return (updateId: string | undefined) => { - if (!updateId) { - return; - } - const recursiveUpdateId = (item: T, index: number, array: T[]) => { - if (item.id === updateId) { - array[index] = { ...item, selected: true }; - } else { - array[index] = { ...item, selected: false }; - } - - if (item.children) { - (item.children as T[]).forEach(recursiveUpdateId); - } - }; - - data.forEach(recursiveUpdateId); - callback(cloneDeep(data)); - return data; - }; -}; diff --git a/test/tree.bench.ts b/test/tree.bench.ts new file mode 100644 index 0000000..39a249b --- /dev/null +++ b/test/tree.bench.ts @@ -0,0 +1,88 @@ +import { bench, describe } from "vitest"; +import { ReactTreeListItemType } from "../src/types"; +import { getItemById, updateItemById, setSelectedById } from "../src/tree"; + +// ─── Tree generator ─────────────────────────────────────────────────────────── + +const makeTree = ( + depth: number, + breadth: number, + prefix = "" +): ReactTreeListItemType[] => + Array.from({ length: breadth }, (_, i) => { + const id = prefix ? `${prefix}.${i}` : String(i); + return { + id, + label: `Item ${id}`, + ...(depth > 1 ? { children: makeTree(depth - 1, breadth, id) } : {}), + }; + }); + +const lastId = (depth: number, breadth: number): string => + Array.from({ length: depth }, () => String(breadth - 1)).join("."); + +const small = makeTree(1, 50); // 50 nodes +const medium = makeTree(3, 8); // 584 nodes +const large = makeTree(4, 8); // 4680 nodes + +// ─── getItemById ────────────────────────────────────────────────────────────── + +describe("getItemById", () => { + bench("small – 50 nodes", () => { + getItemById(small, lastId(1, 50)); + }); + + bench("medium – 584 nodes", () => { + getItemById(medium, lastId(3, 8)); + }); + + bench("large – 4680 nodes", () => { + getItemById(large, lastId(4, 8)); + }); +}); + +// ─── updateItemById ─────────────────────────────────────────────────────────── + +describe("updateItemById – first item (best case)", () => { + bench("small – 50 nodes", () => { + updateItemById(small, "0", { label: "updated" }); + }); + + bench("medium – 584 nodes", () => { + updateItemById(medium, "0", { label: "updated" }); + }); + + bench("large – 4680 nodes", () => { + updateItemById(large, "0", { label: "updated" }); + }); +}); + +describe("updateItemById – last item (worst case)", () => { + bench("small – 50 nodes", () => { + updateItemById(small, lastId(1, 50), { label: "updated" }); + }); + + bench("medium – 584 nodes", () => { + updateItemById(medium, lastId(3, 8), { label: "updated" }); + }); + + bench("large – 4680 nodes", () => { + updateItemById(large, lastId(4, 8), { label: "updated" }); + }); +}); + +// ─── setSelectedById ───────────────────────────────────────────────────────── + +describe("setSelectedById", () => { + bench("small – 50 nodes", () => { + setSelectedById(small, lastId(1, 50)); + }); + + bench("medium – 584 nodes", () => { + setSelectedById(medium, lastId(3, 8)); + }); + + bench("large – 4680 nodes", () => { + setSelectedById(large, lastId(4, 8)); + }); +}); diff --git a/test/useGetItemById.test.tsx b/test/useGetItemById.test.tsx index abb562f..a6f0e03 100644 --- a/test/useGetItemById.test.tsx +++ b/test/useGetItemById.test.tsx @@ -1,5 +1,5 @@ import { ReactTreeListItemType } from "../src"; -import { useGetItemById } from "../src/utils/useGetItemById"; +import { getItemById } from "../src/tree"; const nestedData: ReactTreeListItemType[] = [ { id: "A1", label: "A1" }, @@ -16,20 +16,17 @@ const nestedData: ReactTreeListItemType[] = [ }, ]; -describe("useGetItemById", () => { +describe("getItemById", () => { it("should return undefined for empty data", () => { - const getItemById = useGetItemById([]); - expect(getItemById("")).toBeUndefined(); + expect(getItemById([], "")).toBeUndefined(); }); it("should return undefined when id is not found", () => { - const getItemById = useGetItemById(nestedData); - expect(getItemById("")).toBeUndefined(); + expect(getItemById(nestedData, "")).toBeUndefined(); }); it('should find nested item with id "C2"', () => { - const getItemById = useGetItemById(nestedData); - const item = getItemById("C2"); + const item = getItemById(nestedData, "C2"); expect(item?.id).toBe("C2"); expect(item?.label).toBe("C2"); }); diff --git a/tsconfig.json b/tsconfig.json index 069522e..dca38af 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "target": "ES2018", "module": "ESNext", "moduleResolution": "bundler", - "lib": ["ES2018", "DOM"], + "lib": ["ES2019", "DOM"], "jsx": "react-jsx", "strict": true, "declaration": true, @@ -12,6 +12,6 @@ "skipLibCheck": true, "types": ["vitest/globals"] }, - "include": ["src", "test"], - "exclude": ["node_modules", "stories", "dist"] + "include": ["src", "test", "stories"], + "exclude": ["node_modules", "dist"] } diff --git a/vite.config.ts b/vite.config.ts index 30e8f82..d8fd089 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,7 +7,7 @@ export default defineConfig({ plugins: [react(), dts({ include: ["src"] })], build: { lib: { - entry: resolve(__dirname, "src/index.tsx"), + entry: resolve(__dirname, "src/index.ts"), formats: ["es", "cjs"], fileName: (format) => `index.${format === "es" ? "js" : "cjs"}`, }, @@ -17,8 +17,6 @@ export default defineConfig({ "react-dom", "react/jsx-runtime", "styled-components", - "lodash", - "polished", ], output: { globals: {