From dd46af553930ad5df57db3a6049ff9a2c6483e78 Mon Sep 17 00:00:00 2001 From: biubiukam Date: Wed, 5 Aug 2026 17:05:08 +0800 Subject: [PATCH 1/2] fix: prevent React plugin render loop --- ...9-react-plugins-loop_2026-08-05-16-50.json | 11 ++++ .../__tests__/list-table-plugins.test.tsx | 54 +++++++++++++++++++ packages/react-vtable/jest.config.js | 45 ++++++++++++++++ packages/react-vtable/package.json | 1 + .../react-vtable/src/tables/base-table.tsx | 17 +++--- packages/react-vtable/tscofig.eslint.json | 5 +- 6 files changed, 124 insertions(+), 9 deletions(-) create mode 100644 common/changes/@visactor/react-vtable/fix-issue-4859-react-plugins-loop_2026-08-05-16-50.json create mode 100644 packages/react-vtable/__tests__/list-table-plugins.test.tsx create mode 100644 packages/react-vtable/jest.config.js 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..02176792e1 --- /dev/null +++ b/packages/react-vtable/__tests__/list-table-plugins.test.tsx @@ -0,0 +1,54 @@ +/* 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; + let consoleError: jest.SpyInstance; + + beforeEach(() => { + container = document.createElement('div'); + container.style.width = '800px'; + container.style.height = '400px'; + document.body.appendChild(container); + root = createRoot(container); + consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + consoleError.mockRestore(); + }); + + test('does not enter an update loop when a filter plugin is configured', async () => { + const onReady = jest.fn(); + + await act(async () => { + root.render( + + + + + ); + await new Promise(resolve => setTimeout(resolve, 50)); + }); + + expect(onReady).toHaveBeenCalledTimes(1); + expect(consoleError.mock.calls.some(args => String(args[0]).includes('Maximum update depth exceeded'))).toBe(false); + }); +}); diff --git a/packages/react-vtable/jest.config.js b/packages/react-vtable/jest.config.js new file mode 100644 index 0000000000..16b084c98d --- /dev/null +++ b/packages/react-vtable/jest.config.js @@ -0,0 +1,45 @@ +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-color': path.resolve(__dirname, '../vtable/node_modules/d3-color/dist/d3-color.min.js'), + 'd3-array': path.resolve(__dirname, '../vtable/node_modules/d3-array/dist/d3-array.min.js'), + 'd3-geo': path.resolve(__dirname, '../vtable/node_modules/d3-geo/dist/d3-geo.min.js'), + 'd3-dsv': path.resolve(__dirname, '../vtable/node_modules/d3-dsv/dist/d3-dsv.min.js'), + 'd3-hexbin': path.resolve(__dirname, '../vtable/node_modules/d3-hexbin/build/d3-hexbin.min.js'), + 'd3-hierarchy': path.resolve(__dirname, '../vtable/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 +} From 6379b23f0e75320fc25298773b0793887d81fa56 Mon Sep 17 00:00:00 2001 From: biubiukam Date: Fri, 7 Aug 2026 15:38:08 +0800 Subject: [PATCH 2/2] fix: stabilize React plugin Jest setup --- .../__tests__/list-table-plugins.test.tsx | 11 ++++---- packages/react-vtable/jest.config.js | 26 ++++++++++++++----- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/packages/react-vtable/__tests__/list-table-plugins.test.tsx b/packages/react-vtable/__tests__/list-table-plugins.test.tsx index 02176792e1..e080bb782b 100644 --- a/packages/react-vtable/__tests__/list-table-plugins.test.tsx +++ b/packages/react-vtable/__tests__/list-table-plugins.test.tsx @@ -9,7 +9,6 @@ import { ListColumn, ListTable } from '../src'; describe('ListTable plugins', () => { let container: HTMLDivElement; let root: Root; - let consoleError: jest.SpyInstance; beforeEach(() => { container = document.createElement('div'); @@ -17,7 +16,6 @@ describe('ListTable plugins', () => { container.style.height = '400px'; document.body.appendChild(container); root = createRoot(container); - consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined); }); afterEach(() => { @@ -25,11 +23,15 @@ describe('ListTable plugins', () => { root.unmount(); }); container.remove(); - consoleError.mockRestore(); }); 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( @@ -45,10 +47,9 @@ describe('ListTable plugins', () => { ); - await new Promise(resolve => setTimeout(resolve, 50)); }); + await ready; expect(onReady).toHaveBeenCalledTimes(1); - expect(consoleError.mock.calls.some(args => String(args[0]).includes('Maximum update depth exceeded'))).toBe(false); }); }); diff --git a/packages/react-vtable/jest.config.js b/packages/react-vtable/jest.config.js index 16b084c98d..31f4177a67 100644 --- a/packages/react-vtable/jest.config.js +++ b/packages/react-vtable/jest.config.js @@ -29,12 +29,26 @@ module.exports = { }, cacheDirectory: '/.jest-cache', moduleNameMapper: { - 'd3-color': path.resolve(__dirname, '../vtable/node_modules/d3-color/dist/d3-color.min.js'), - 'd3-array': path.resolve(__dirname, '../vtable/node_modules/d3-array/dist/d3-array.min.js'), - 'd3-geo': path.resolve(__dirname, '../vtable/node_modules/d3-geo/dist/d3-geo.min.js'), - 'd3-dsv': path.resolve(__dirname, '../vtable/node_modules/d3-dsv/dist/d3-dsv.min.js'), - 'd3-hexbin': path.resolve(__dirname, '../vtable/node_modules/d3-hexbin/build/d3-hexbin.min.js'), - 'd3-hierarchy': path.resolve(__dirname, '../vtable/node_modules/d3-hierarchy/dist/d3-hierarchy.min.js'), + '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',