diff --git a/common/changes/@visactor/react-vtable/fix-issue-4859-react-plugins-loop_2026-08-05-16-50.json b/common/changes/@visactor/react-vtable/fix-issue-4859-react-plugins-loop_2026-08-05-16-50.json new file mode 100644 index 0000000000..9c39da21f5 --- /dev/null +++ b/common/changes/@visactor/react-vtable/fix-issue-4859-react-plugins-loop_2026-08-05-16-50.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@visactor/react-vtable", + "comment": "fix: prevent plugin options from causing a React render loop (GitHub #4859)", + "type": "patch" + } + ], + "packageName": "@visactor/react-vtable", + "email": "biukam.w@gmail.com" +} diff --git a/packages/react-vtable/__tests__/list-table-plugins.test.tsx b/packages/react-vtable/__tests__/list-table-plugins.test.tsx new file mode 100644 index 0000000000..e080bb782b --- /dev/null +++ b/packages/react-vtable/__tests__/list-table-plugins.test.tsx @@ -0,0 +1,55 @@ +/* eslint-env jest, browser */ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createRoot } from 'react-dom/client'; +import type { Root } from 'react-dom/client'; +import { FilterPlugin } from '../../vtable-plugins/src/filter'; +import { ListColumn, ListTable } from '../src'; + +describe('ListTable plugins', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + container.style.width = '800px'; + container.style.height = '400px'; + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + }); + + test('does not enter an update loop when a filter plugin is configured', async () => { + let readyResolve!: () => void; + const ready = new Promise(resolve => { + readyResolve = resolve; + }); + const onReady = jest.fn(); + onReady.mockImplementationOnce(() => readyResolve()); + + await act(async () => { + root.render( + + + + + ); + }); + await ready; + + expect(onReady).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/react-vtable/jest.config.js b/packages/react-vtable/jest.config.js new file mode 100644 index 0000000000..31f4177a67 --- /dev/null +++ b/packages/react-vtable/jest.config.js @@ -0,0 +1,59 @@ +const path = require('path'); +const { createVRenderModuleNameMapper } = require('../../common/config/jest/vrender-module-name-mapper'); + +module.exports = { + preset: 'ts-jest', + runner: 'jest-electron/runner', + testEnvironment: 'jest-electron/environment', + testRegex: '/__tests__(/.*)+\\.test\\.(js|ts|tsx)$', + silent: false, + verbose: true, + globals: { + 'ts-jest': { + diagnostics: false, + isolatedModules: true, + tsconfig: { + resolveJsonModule: true, + esModuleInterop: true, + jsx: 'react', + baseUrl: '.', + paths: { + '@visactor/vtable': ['../vtable/src/index'], + '@visactor/vtable/*': ['../vtable/src/*'], + '@src/*': ['../vtable/src/*'], + '@vutils-extension': ['../vtable/src/vutil-extension-temp/index'] + } + } + }, + __DEV__: true + }, + cacheDirectory: '/.jest-cache', + moduleNameMapper: { + 'd3-array': path.resolve( + __dirname, + '../../common/temp/node_modules/.pnpm/d3-array@3.2.3/node_modules/d3-array/dist/d3-array.min.js' + ), + 'd3-geo': path.resolve( + __dirname, + '../../common/temp/node_modules/.pnpm/d3-geo@3.1.1/node_modules/d3-geo/dist/d3-geo.min.js' + ), + 'd3-dsv': path.resolve( + __dirname, + '../../common/temp/node_modules/.pnpm/d3-dsv@3.0.1/node_modules/d3-dsv/dist/d3-dsv.min.js' + ), + 'd3-hexbin': path.resolve( + __dirname, + '../../common/temp/node_modules/.pnpm/d3-hexbin@0.2.2/node_modules/d3-hexbin/build/d3-hexbin.min.js' + ), + 'd3-hierarchy': path.resolve( + __dirname, + '../../common/temp/node_modules/.pnpm/d3-hierarchy@3.1.2/node_modules/d3-hierarchy/dist/d3-hierarchy.min.js' + ), + ...createVRenderModuleNameMapper('/../vtable/node_modules'), + '@visactor/vtable$': '/../vtable/src/index', + '@visactor/vtable/es/(.*)': '/../vtable/src/$1', + '@src/(.*)': '/../vtable/src/$1', + '@vutils-extension': '/../vtable/src/vutil-extension-temp/index' + }, + setupFiles: ['./setup-mock.js'] +}; diff --git a/packages/react-vtable/package.json b/packages/react-vtable/package.json index 3295617b53..1cdc31dfb4 100644 --- a/packages/react-vtable/package.json +++ b/packages/react-vtable/package.json @@ -41,6 +41,7 @@ "start:react19": "npm run setup:react19-deps && vite --config ./demo/vite.config.react19.ts ./demo", "build": "npm run fix-memory-limit && bundle --clean", "compile": "tsc --noEmit", + "test": "jest --config jest.config.js", "eslint": "eslint --debug --fix src/", "fix-memory-limit": "cross-env LIMIT=10240 increase-memory-limit" }, diff --git a/packages/react-vtable/src/tables/base-table.tsx b/packages/react-vtable/src/tables/base-table.tsx index 8d96949aa2..1616be9fcd 100644 --- a/packages/react-vtable/src/tables/base-table.tsx +++ b/packages/react-vtable/src/tables/base-table.tsx @@ -139,6 +139,8 @@ const BaseTable: React.FC = React.forwardRef((props, ref) => { const optionFromChildren = useRef>(null); const prevRecords = useRef(props.records); const eventsBinded = React.useRef(null); + const latestProps = useRef(props); + const isInitialReady = useRef(true); const skipFunctionDiff = !!props.skipFunctionDiff; const keepColumnWidthChange = !!props.keepColumnWidthChange; const columnWidths = useRef>(new Map()); @@ -148,6 +150,7 @@ const BaseTable: React.FC = React.forwardRef((props, ref) => { if (tableContext.current) { tableContext.current.onError = props.onError; } + latestProps.current = props; const parseOption = useCallback( (props: Props) => { @@ -235,17 +238,17 @@ const BaseTable: React.FC = React.forwardRef((props, ref) => { if (!tableContext.current || !tableContext.current.table) { return; } + const currentProps = latestProps.current; // rebind events after render - bindEventsToTable(tableContext.current.table, props, eventsBinded.current, TABLE_EVENTS); + bindEventsToTable(tableContext.current.table, currentProps, eventsBinded.current, TABLE_EVENTS); - // to be fixed - // will cause another useEffect - setUpdateId(updateId + 1); - if (props.onReady) { - props.onReady(tableContext.current.table, updateId === 0); + setUpdateId(currentUpdateId => currentUpdateId + 1); + if (currentProps.onReady) { + currentProps.onReady(tableContext.current.table, isInitialReady.current); } + isInitialReady.current = false; } - }, [updateId, setUpdateId, props]); + }, []); const renderTable = useCallback(() => { if (tableContext.current.table) { diff --git a/packages/react-vtable/tscofig.eslint.json b/packages/react-vtable/tscofig.eslint.json index a8b2b56da8..e275387533 100644 --- a/packages/react-vtable/tscofig.eslint.json +++ b/packages/react-vtable/tscofig.eslint.json @@ -15,6 +15,7 @@ }, "include": [ "src", - "demo" + "demo", + "__tests__" ] -} \ No newline at end of file +}