fix(inbounds): list clients in the detach/attach modals when they mount open (#6736)

* fix(inbounds): list clients in the detach modal when it mounts open

DetachClientsModal seeded its synced-source state with the open source,
so a modal first mounted with open and a source saw no change on its
first render and never read the client rows. The table stayed empty
until the modal was closed and reopened.

Seed the state with null so the first open render loads the rows, as
the reset-during-render check already expects. The test mounts the
modal already open and fails without this change.

Fixes #6733

* fix(inbounds): list clients in the attach modal when it mounts open

AttachClientsModal seeds its synced-source state the same way
DetachClientsModal did, so a modal first mounted with open and a source
saw no change on its first render and never read the client rows. The
table stayed empty, and nothing was pre-selected, until the modal was
closed and reopened.

Seed the state with null here too. The test mounts the attach modal
already open and fails without this change.

Refs #6733

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
Younes Beriane
2026-10-05 15:27:56 +02:00
committed by GitHub
parent d1b60799ec
commit d4a7086c4e
4 changed files with 81 additions and 2 deletions
@@ -60,7 +60,7 @@ export default function AttachClientsModal({
// React resets this during render rather than in an effect so the modal's
// first open frame already shows cleared fields.
const openSource = open ? source : null;
const [syncedSource, setSyncedSource] = useState(openSource);
const [syncedSource, setSyncedSource] = useState<DBInbound | null>(null);
if (openSource !== syncedSource) {
setSyncedSource(openSource);
if (openSource) {
@@ -54,7 +54,7 @@ export default function DetachClientsModal({
// Reset during render, not in an effect, so the first frame is already clean.
const openSource = open ? source : null;
const [syncedSource, setSyncedSource] = useState(openSource);
const [syncedSource, setSyncedSource] = useState<DBInbound | null>(null);
if (openSource !== syncedSource) {
setSyncedSource(openSource);
if (openSource) {
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { screen } from '@testing-library/react';
import AttachClientsModal from '@/pages/inbounds/clients/AttachClientsModal';
import { DBInbound } from '@/models/dbinbound';
import { renderWithProviders } from './test-utils';
function sourceInbound() {
return new DBInbound({
id: 7,
port: 443,
listen: '',
protocol: 'vless',
remark: 'edge',
enable: true,
settings: JSON.stringify({
clients: [
{ id: 'uuid-1', email: 'alice@test' },
{ id: 'uuid-2', email: 'bob@test' },
],
decryption: 'none',
}),
streamSettings: JSON.stringify({ network: 'tcp', security: 'none' }),
sniffing: '',
});
}
describe('AttachClientsModal', () => {
it('lists the source clients, selected, when first mounted already open', async () => {
renderWithProviders(
<AttachClientsModal open source={sourceInbound()} dbInbounds={[]} onClose={() => {}} />,
);
expect(await screen.findByText('alice@test')).toBeTruthy();
expect(screen.getByText('bob@test')).toBeTruthy();
const boxes = screen.getAllByRole('checkbox') as HTMLInputElement[];
const rowBoxes = boxes.slice(1);
expect(rowBoxes).toHaveLength(2);
expect(rowBoxes.every((b) => b.checked)).toBe(true);
});
});
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { screen } from '@testing-library/react';
import DetachClientsModal from '@/pages/inbounds/clients/DetachClientsModal';
import { DBInbound } from '@/models/dbinbound';
import { renderWithProviders } from './test-utils';
function sourceInbound() {
return new DBInbound({
id: 7,
port: 443,
listen: '',
protocol: 'vless',
remark: 'edge',
enable: true,
settings: JSON.stringify({
clients: [
{ id: 'uuid-1', email: 'alice@test' },
{ id: 'uuid-2', email: 'bob@test' },
],
decryption: 'none',
}),
streamSettings: JSON.stringify({ network: 'tcp', security: 'none' }),
sniffing: '',
});
}
describe('DetachClientsModal', () => {
it('lists the attached clients when first mounted already open', async () => {
renderWithProviders(<DetachClientsModal open source={sourceInbound()} onClose={() => {}} />);
expect(await screen.findByText('alice@test')).toBeTruthy();
expect(screen.getByText('bob@test')).toBeTruthy();
});
});