diff --git a/eslint.config.js b/eslint.config.js index aa4f2cf287..9023668536 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -4,7 +4,9 @@ import tsParser from '@typescript-eslint/parser'; import vitest from '@vitest/eslint-plugin'; import jestDom from 'eslint-plugin-jest-dom'; import react from 'eslint-plugin-react'; +import reactCompiler from 'eslint-plugin-react-compiler'; import reactHooks from 'eslint-plugin-react-hooks'; +import reactHooksExtra from 'eslint-plugin-react-hooks-extra'; import sonarjs from 'eslint-plugin-sonarjs'; import testingLibrary from 'eslint-plugin-testing-library'; @@ -18,7 +20,9 @@ export default [ plugins: { react, + 'react-compiler': reactCompiler, 'react-hooks': fixupPluginRules(reactHooks), + 'react-hooks-extra': reactHooksExtra, sonarjs, '@typescript-eslint': typescriptEslint }, @@ -371,11 +375,22 @@ export default [ 'react/style-prop-object': 0, 'react/void-dom-elements-no-children': 1, + // React Compiler + // https://react.dev/learn/react-compiler#installing-eslint-plugin-react-compiler + 'react-compiler/react-compiler': 1, + // React Hooks // https://www.npmjs.com/package/eslint-plugin-react-hooks 'react-hooks/rules-of-hooks': 1, 'react-hooks/exhaustive-deps': 1, + // React Hooks Extra + // https://eslint-react.xyz/ + 'react-hooks-extra/no-redundant-custom-hook': 1, + 'react-hooks-extra/no-unnecessary-use-callback': 1, + 'react-hooks-extra/no-unnecessary-use-memo': 1, + 'react-hooks-extra/prefer-use-state-lazy-initialization': 1, + // SonarJS rules // https://github.com/SonarSource/eslint-plugin-sonarjs#rules 'sonarjs/no-all-duplicated-branches': 1, @@ -467,13 +482,14 @@ export default [ '@typescript-eslint/no-this-alias': 0, '@typescript-eslint/no-type-alias': 0, '@typescript-eslint/no-unnecessary-boolean-literal-compare': 1, - '@typescript-eslint/no-unnecessary-condition': 1, + '@typescript-eslint/no-unnecessary-condition': [1, { checkTypePredicates: true }], '@typescript-eslint/no-unnecessary-parameter-property-assignment': 1, '@typescript-eslint/no-unnecessary-qualifier': 0, '@typescript-eslint/no-unnecessary-template-expression': 1, '@typescript-eslint/no-unnecessary-type-arguments': 1, '@typescript-eslint/no-unnecessary-type-assertion': 1, '@typescript-eslint/no-unnecessary-type-constraint': 1, + '@typescript-eslint/no-unnecessary-type-parameters': 1, '@typescript-eslint/no-unsafe-argument': 0, '@typescript-eslint/no-unsafe-assignment': 0, '@typescript-eslint/no-unsafe-call': 0, @@ -587,7 +603,7 @@ export default [ plugins: { vitest, 'jest-dom': jestDom, - 'testing-library': fixupPluginRules(testingLibrary) + 'testing-library': testingLibrary }, rules: { @@ -647,6 +663,7 @@ export default [ 'vitest/prefer-to-contain': 1, 'vitest/prefer-to-have-length': 1, 'vitest/prefer-todo': 1, + 'vitest/prefer-vi-mocked': 1, 'vitest/require-hook': 0, 'vitest/require-local-test-context-for-concurrent-snapshots': 0, 'vitest/require-to-throw-message': 0, diff --git a/package.json b/package.json index 92666a98dd..961778cae0 100644 --- a/package.json +++ b/package.json @@ -62,37 +62,39 @@ "@babel/preset-typescript": "^7.18.6", "@babel/runtime": "^7.21.5", "@biomejs/biome": "1.9.4", - "@eslint/compat": "^1.1.1", + "@eslint/compat": "^1.2.2", "@faker-js/faker": "^9.0.0", "@ianvs/prettier-plugin-sort-imports": "^4.0.2", "@linaria/core": "^6.0.0", "@microsoft/api-extractor": "^7.23.0", "@rollup/plugin-babel": "^6.0.3", "@rollup/plugin-node-resolve": "^15.1.0", - "@tanstack/react-router": "^1.57.13", - "@tanstack/router-plugin": "^1.57.13", + "@tanstack/react-router": "^1.70.0", + "@tanstack/router-plugin": "^1.69.1", "@testing-library/dom": "^10.1.0", "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.5.2", "@types/node": "^22.0.0", "@types/react": "^18.3.9", "@types/react-dom": "^18.3.0", - "@typescript-eslint/eslint-plugin": "^8.7.0", - "@typescript-eslint/parser": "^8.7.0", + "@typescript-eslint/eslint-plugin": "^8.13.0", + "@typescript-eslint/parser": "^8.13.0", "@vitejs/plugin-react": "^4.3.1", "@vitest/browser": "^2.1.1", "@vitest/coverage-v8": "^2.1.1", - "@vitest/eslint-plugin": "^1.1.4", + "@vitest/eslint-plugin": "^1.1.8", "@wyw-in-js/rollup": "^0.5.0", "@wyw-in-js/vite": "^0.5.0", "babel-plugin-optimize-clsx": "^2.6.2", "browserslist": "^4.24.0", - "eslint": "^9.11.1", + "eslint": "^9.14.0", "eslint-plugin-jest-dom": "^5.0.1", - "eslint-plugin-react": "^7.36.1", + "eslint-plugin-react": "^7.37.2", + "eslint-plugin-react-compiler": "^19.0.0-beta-a7bf2bd-20241110", "eslint-plugin-react-hooks": "^5.0.0", - "eslint-plugin-sonarjs": "^2.0.2", - "eslint-plugin-testing-library": "^6.3.0", + "eslint-plugin-react-hooks-extra": "^1.16.1", + "eslint-plugin-sonarjs": "^2.0.4", + "eslint-plugin-testing-library": "^6.4.0", "jspdf": "^2.5.1", "jspdf-autotable": "^3.5.23", "playwright": "^1.45.1", diff --git a/src/DataGrid.tsx b/src/DataGrid.tsx index 314ec4a149..a16597e6fc 100644 --- a/src/DataGrid.tsx +++ b/src/DataGrid.tsx @@ -296,6 +296,8 @@ function DataGrid( const [isDragging, setDragging] = useState(false); const [draggedOverRowIdx, setOverRowIdx] = useState(undefined); const [scrollToPosition, setScrollToPosition] = useState(null); + const [shouldFocusCell, setShouldFocusCell] = useState(false); + const [previousRowIdx, setPreviousRowIdx] = useState(-1); const getColumnWidth = useCallback( (column: CalculatedColumn) => { @@ -338,15 +340,13 @@ function DataGrid( const [selectedPosition, setSelectedPosition] = useState( (): SelectCellState | EditCellState => ({ idx: -1, rowIdx: minRowIdx - 1, mode: 'SELECT' }) ); + const [prevSelectedPosition, setPrevSelectedPosition] = useState(selectedPosition); /** * refs */ - const prevSelectedPosition = useRef(selectedPosition); const latestDraggedOverRowIdx = useRef(draggedOverRowIdx); - const lastSelectedRowIdx = useRef(-1); const focusSinkRef = useRef(null); - const shouldFocusCellRef = useRef(false); /** * computed values @@ -458,31 +458,50 @@ function DataGrid( selectCell({ rowIdx: minRowIdx + rowIdx - 1, idx }); }); + /** + * callbacks + */ + const setDraggedOverRowIdx = useCallback((rowIdx?: number) => { + setOverRowIdx(rowIdx); + latestDraggedOverRowIdx.current = rowIdx; + }, []); + + const focusCellOrCellContent = useCallback(() => { + const cell = getCellToScroll(gridRef.current!); + if (cell === null) return; + + scrollIntoView(cell); + // Focus cell content when available instead of the cell itself + const elementToFocus = cell.querySelector('[tabindex="0"]') ?? cell; + elementToFocus.focus({ preventScroll: true }); + }, [gridRef]); + /** * effects */ useLayoutEffect(() => { if ( !selectedCellIsWithinSelectionBounds || - isSamePosition(selectedPosition, prevSelectedPosition.current) + isSamePosition(selectedPosition, prevSelectedPosition) ) { - prevSelectedPosition.current = selectedPosition; + setPrevSelectedPosition(selectedPosition); return; } - prevSelectedPosition.current = selectedPosition; + setPrevSelectedPosition(selectedPosition); - if (selectedPosition.idx === -1) { - focusSinkRef.current!.focus({ preventScroll: true }); + if (focusSinkRef.current !== null && selectedPosition.idx === -1) { + focusSinkRef.current.focus({ preventScroll: true }); scrollIntoView(focusSinkRef.current); } - }); + }, [selectedCellIsWithinSelectionBounds, selectedPosition, prevSelectedPosition]); useLayoutEffect(() => { - if (!shouldFocusCellRef.current) return; - shouldFocusCellRef.current = false; - focusCellOrCellContent(); - }); + if (shouldFocusCell) { + setShouldFocusCell(false); + focusCellOrCellContent(); + } + }, [shouldFocusCell, focusCellOrCellContent]); useImperativeHandle(ref, () => ({ element: gridRef.current, @@ -499,14 +518,6 @@ function DataGrid( selectCell })); - /** - * callbacks - */ - const setDraggedOverRowIdx = useCallback((rowIdx?: number) => { - setOverRowIdx(rowIdx); - latestDraggedOverRowIdx.current = rowIdx; - }, []); - /** * event handlers */ @@ -536,9 +547,8 @@ function DataGrid( if (isRowSelectionDisabled?.(row) === true) return; const newSelectedRows = new Set(selectedRows); const rowKey = rowKeyGetter(row); - const previousRowIdx = lastSelectedRowIdx.current; const rowIdx = rows.indexOf(row); - lastSelectedRowIdx.current = rowIdx; + setPreviousRowIdx(rowIdx); if (checked) { newSelectedRows.add(rowKey); @@ -758,7 +768,7 @@ function DataGrid( // Avoid re-renders if the selected cell state is the same scrollIntoView(getCellToScroll(gridRef.current!)); } else { - shouldFocusCellRef.current = true; + setShouldFocusCell(true); setSelectedPosition({ ...position, mode: 'SELECT' }); } @@ -870,16 +880,6 @@ function DataGrid( return isDraggedOver ? selectedPosition.idx : undefined; } - function focusCellOrCellContent() { - const cell = getCellToScroll(gridRef.current!); - if (cell === null) return; - - scrollIntoView(cell); - // Focus cell content when available instead of the cell itself - const elementToFocus = cell.querySelector('[tabindex="0"]') ?? cell; - elementToFocus.focus({ preventScroll: true }); - } - function renderDragHandle() { if ( onFill == null || @@ -925,7 +925,7 @@ function DataGrid( const colSpan = getColSpan(column, lastFrozenColumnIndex, { type: 'ROW', row }); const closeEditor = (shouldFocusCell: boolean) => { - shouldFocusCellRef.current = shouldFocusCell; + setShouldFocusCell(shouldFocusCell); setSelectedPosition(({ idx, rowIdx }) => ({ idx, rowIdx, mode: 'SELECT' })); }; @@ -1061,6 +1061,7 @@ function DataGrid( // Reset the positions if the current values are no longer valid. This can happen if a column or row is removed if (selectedPosition.idx > maxColIdx || selectedPosition.rowIdx > maxRowIdx) { setSelectedPosition({ idx: -1, rowIdx: minRowIdx - 1, mode: 'SELECT' }); + // eslint-disable-next-line react-compiler/react-compiler setDraggedOverRowIdx(undefined); } @@ -1182,6 +1183,7 @@ function DataGrid( ); })} + {/* eslint-disable-next-line react-compiler/react-compiler */} {getViewportRows()} {bottomSummaryRows?.map((row, rowIdx) => { @@ -1245,7 +1247,7 @@ function DataGrid( )} diff --git a/src/ScrollToCell.tsx b/src/ScrollToCell.tsx index 7fca2ce353..5c8a228eec 100644 --- a/src/ScrollToCell.tsx +++ b/src/ScrollToCell.tsx @@ -10,11 +10,11 @@ export interface PartialPosition { export default function ScrollToCell({ scrollToPosition: { idx, rowIdx }, - gridElement, + gridRef, setScrollToCellPosition }: { scrollToPosition: PartialPosition; - gridElement: HTMLDivElement; + gridRef: React.RefObject; setScrollToCellPosition: (cell: null) => void; }) { const ref = useRef(null); @@ -31,7 +31,7 @@ export default function ScrollToCell({ } const observer = new IntersectionObserver(removeScrollToCell, { - root: gridElement, + root: gridRef.current!, threshold: 1.0 }); @@ -40,7 +40,7 @@ export default function ScrollToCell({ return () => { observer.disconnect(); }; - }, [gridElement, setScrollToCellPosition]); + }, [gridRef, setScrollToCellPosition]); return (
({ const updateStartIdx = (colIdx: number, colSpan: number | undefined) => { if (colSpan !== undefined && colIdx + colSpan > colOverscanStartIdx) { + // eslint-disable-next-line react-compiler/react-compiler startIdx = colIdx; return true; } diff --git a/src/utils/index.ts b/src/utils/index.ts index 35e428b772..f6bd990888 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,4 +1,4 @@ -import type { CalculatedColumn, CalculatedColumnOrColumnGroup } from '../types'; +import type { CalculatedColumn, CalculatedColumnOrColumnGroup, Maybe } from '../types'; export * from './colSpanUtils'; export * from './domUtils'; @@ -11,7 +11,7 @@ export * from './styleUtils'; export const { min, max, floor, sign, abs } = Math; export function assertIsValidKeyGetter( - keyGetter: unknown + keyGetter: Maybe<(row: NoInfer) => K> ): asserts keyGetter is (row: R) => K { if (typeof keyGetter !== 'function') { throw new Error('Please specify the rowKeyGetter prop to use selection'); diff --git a/tsconfig.website.json b/tsconfig.website.json index 8c78a220cf..8094a617b1 100644 --- a/tsconfig.website.json +++ b/tsconfig.website.json @@ -1,8 +1,7 @@ { "extends": "./tsconfig.base.json", "compilerOptions": { - "lib": ["ESNext", "DOM", "DOM.Iterable", "DOM.AsyncIterable"], - "skipLibCheck": true + "lib": ["ESNext", "DOM", "DOM.Iterable", "DOM.AsyncIterable"] }, "include": ["website/**/*"], "references": [{ "path": "tsconfig.src.json" }] diff --git a/website/routes/ColumnSpanning.lazy.tsx b/website/routes/ColumnSpanning.lazy.tsx index e393e2e26d..1862c94cad 100644 --- a/website/routes/ColumnSpanning.lazy.tsx +++ b/website/routes/ColumnSpanning.lazy.tsx @@ -1,4 +1,3 @@ -import { useMemo } from 'react'; import { createLazyFileRoute } from '@tanstack/react-router'; import { css } from '@linaria/core'; @@ -20,49 +19,45 @@ const colSpanClassname = css` text-align: center; `; -function ColumnSpanning() { - const direction = useDirection(); - - const columns = useMemo((): readonly Column[] => { - const columns: Column[] = []; +const columns: Column[] = []; - for (let i = 0; i < 30; i++) { - const key = String(i); - columns.push({ - key, - name: key, - frozen: i < 5, - resizable: true, - renderCell: renderCoordinates, - colSpan(args) { - if (args.type === 'ROW') { - if (key === '2' && args.row === 2) return 3; - if (key === '4' && args.row === 4) return 6; // Will not work as colspan includes both frozen and regular columns - if (key === '0' && args.row === 5) return 5; - if (key === '27' && args.row === 8) return 3; - if (key === '6' && args.row < 8) return 2; - } - if (args.type === 'HEADER' && key === '8') { - return 3; - } - return undefined; - }, - cellClass(row) { - if ( - (key === '0' && row === 5) || - (key === '2' && row === 2) || - (key === '27' && row === 8) || - (key === '6' && row < 8) - ) { - return colSpanClassname; - } - return undefined; - } - }); +for (let i = 0; i < 30; i++) { + const key = String(i); + columns.push({ + key, + name: key, + frozen: i < 5, + resizable: true, + renderCell: renderCoordinates, + colSpan(args) { + if (args.type === 'ROW') { + if (key === '2' && args.row === 2) return 3; + if (key === '4' && args.row === 4) return 6; // Will not work as colspan includes both frozen and regular columns + if (key === '0' && args.row === 5) return 5; + if (key === '27' && args.row === 8) return 3; + if (key === '6' && args.row < 8) return 2; + } + if (args.type === 'HEADER' && key === '8') { + return 3; + } + return undefined; + }, + cellClass(row) { + if ( + (key === '0' && row === 5) || + (key === '2' && row === 2) || + (key === '27' && row === 8) || + (key === '6' && row < 8) + ) { + return colSpanClassname; + } + return undefined; } + }); +} - return columns; - }, []); +function ColumnSpanning() { + const direction = useDirection(); return ( (); for (let i = 0; i < 1000; i++) { + const country = faker.location.country(); + countrySet.add(country); + rows.push({ id: i, title: `Task #${i + 1}`, client: faker.company.name(), area: faker.person.jobArea(), - country: faker.location.country(), + country, contact: faker.internet.exampleEmail(), assignee: faker.person.fullName(), progress: Math.random() * 100, @@ -270,6 +276,8 @@ function createRows(): readonly Row[] { }); } + countries = [...countrySet].sort(new Intl.Collator().compare); + return rows; } @@ -313,12 +321,7 @@ function CommonFeatures() { const [selectedRows, setSelectedRows] = useState((): ReadonlySet => new Set()); const [isExporting, setIsExporting] = useState(false); const gridRef = useRef(null); - - const countries = useMemo((): readonly string[] => { - return [...new Set(rows.map((r) => r.country))].sort(new Intl.Collator().compare); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - const columns = useMemo(() => getColumns(countries, direction), [countries, direction]); + const columns = useMemo(() => getColumns(countries, direction), [direction]); const summaryRows = useMemo((): readonly SummaryRow[] => { return [ diff --git a/website/routes/MillionCells.lazy.tsx b/website/routes/MillionCells.lazy.tsx index fb3b2975bc..eafaeb467f 100644 --- a/website/routes/MillionCells.lazy.tsx +++ b/website/routes/MillionCells.lazy.tsx @@ -1,4 +1,3 @@ -import { useMemo } from 'react'; import { createLazyFileRoute } from '@tanstack/react-router'; import DataGrid from '../../src'; @@ -13,27 +12,23 @@ export const Route = createLazyFileRoute('/MillionCells')({ type Row = number; const rows: readonly Row[] = Array.from({ length: 1000 }, (_, i) => i); +const columns: Column[] = []; + +for (let i = 0; i < 1000; i++) { + const key = String(i); + columns.push({ + key, + name: key, + frozen: i < 5, + width: 80, + resizable: true, + renderCell: renderCoordinates + }); +} + function MillionCells() { const direction = useDirection(); - const columns = useMemo((): readonly Column[] => { - const columns: Column[] = []; - - for (let i = 0; i < 1000; i++) { - const key = String(i); - columns.push({ - key, - name: key, - frozen: i < 5, - width: 80, - resizable: true, - renderCell: renderCoordinates - }); - } - - return columns; - }, []); - return ( i); +const columns: Column[] = []; + +for (let i = 0; i < 30; i++) { + const key = String(i); + columns.push({ + key, + name: key, + frozen: i < 5, + resizable: true, + renderCell: renderCoordinates + }); +} + function VariableRowHeight() { const direction = useDirection(); - const columns = useMemo((): readonly Column[] => { - const columns: Column[] = []; - - for (let i = 0; i < 30; i++) { - const key = String(i); - columns.push({ - key, - name: key, - frozen: i < 5, - resizable: true, - renderCell: renderCoordinates - }); - } - - return columns; - }, []); - return (