From 8662c3129613463d41011b76b9b2f653e3181c8d Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Wed, 15 Jul 2026 05:31:42 +0200 Subject: [PATCH] fix(frontend): key the node table by the computed row key, not id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop node table used rowKey="id", but transitive sub-nodes (the read-only rows surfaced from downstream nodes) all carry id 0, so a topology with two or more transitive rows gave React duplicate keys. antd's rowKey prop overrides the row object's own computed `key` (`t-${guid}` for transitive rows, the numeric id otherwise), so the unique key the code already builds was ignored — causing row-state/DOM mis-association on any re-render (heartbeat refetch, address-eye toggle). The mobile card path already keyed by record.key. Key the table by "key" so transitive rows get their distinct t-${guid} identity; direct nodes keep key === id, so row selection (filtered to numeric keys) is unchanged. --- frontend/src/pages/nodes/NodeList.tsx | 2 +- frontend/src/test/node-list-rowkey.test.tsx | 46 +++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 frontend/src/test/node-list-rowkey.test.tsx diff --git a/frontend/src/pages/nodes/NodeList.tsx b/frontend/src/pages/nodes/NodeList.tsx index 0b850af24..aec149054 100644 --- a/frontend/src/pages/nodes/NodeList.tsx +++ b/frontend/src/pages/nodes/NodeList.tsx @@ -650,7 +650,7 @@ export default function NodeList({ loading={loading} scroll={{ x: 'max-content' }} size="middle" - rowKey="id" + rowKey="key" rowSelection={dataSource.length > 1 ? { selectedRowKeys: selectedIds, onChange: (keys) => onSelectionChange(keys.filter((k) => typeof k === 'number') as number[]), diff --git a/frontend/src/test/node-list-rowkey.test.tsx b/frontend/src/test/node-list-rowkey.test.tsx new file mode 100644 index 000000000..dae7a71c3 --- /dev/null +++ b/frontend/src/test/node-list-rowkey.test.tsx @@ -0,0 +1,46 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; + +import NodeList from '@/pages/nodes/NodeList'; +import type { NodeRecord } from '@/schemas/node'; + +import { renderWithProviders } from './test-utils'; + +const noop = () => {}; + +function sampleNodes(): NodeRecord[] { + return [ + { id: 1, name: 'parent', guid: 'p1', transitive: false, enable: true, status: 'online' }, + { id: 0, name: 'child-a', guid: 'ca', parentGuid: 'p1', transitive: true }, + { id: 0, name: 'child-b', guid: 'cb', parentGuid: 'p1', transitive: true }, + ]; +} + +describe('NodeList desktop table row keys', () => { + afterEach(() => vi.restoreAllMocks()); + + it('gives transitive sub-node rows distinct keys instead of colliding on id 0', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + renderWithProviders( + , + ); + + const duplicateKeyWarning = errorSpy.mock.calls.some((call) => + call.some((arg) => typeof arg === 'string' && arg.includes('same key')), + ); + expect(duplicateKeyWarning).toBe(false); + }); +});