diff --git a/frontend/src/pages/clients/ClientFormModal.tsx b/frontend/src/pages/clients/ClientFormModal.tsx index 0534a81c7..b7673053d 100644 --- a/frontend/src/pages/clients/ClientFormModal.tsx +++ b/frontend/src/pages/clients/ClientFormModal.tsx @@ -373,10 +373,15 @@ export default function ClientFormModal({ } useEffect(() => { - if (!showFlow && flow) { + // Only clear the flow once we actually have inbound options to judge + // capability from. While the options list is momentarily empty (e.g. the + // options query is (re)loading and `inbounds` falls back to `[]`), showFlow + // is a false negative, so clearing here would silently drop a valid + // xtls-rprx-vision flow the user picked for a Reality/TLS inbound. + if (inbounds.length > 0 && !showFlow && flow) { methods.setValue('flow', ''); } - }, [showFlow, flow, methods]); + }, [inbounds, showFlow, flow, methods]); useEffect(() => { if (!showReverseTag && reverseTag) { diff --git a/frontend/src/test/client-form-flow.test.tsx b/frontend/src/test/client-form-flow.test.tsx new file mode 100644 index 000000000..729b7e454 --- /dev/null +++ b/frontend/src/test/client-form-flow.test.tsx @@ -0,0 +1,70 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, fireEvent, waitFor, screen } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +import { ThemeProvider } from '@/hooks/useTheme'; +import ClientFormModal from '@/pages/clients/ClientFormModal'; +import type { ClientRecord, InboundOption } from '@/hooks/useClients'; + +function makeQC() { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }); +} + +const REALITY_INBOUND = { + id: 4, + port: 10443, + protocol: 'vless', + tag: 'in-10443-tcp', + tlsFlowCapable: true, + enable: true, +} as unknown as InboundOption; + +const CLIENT = { + email: 'testuser', + flow: 'xtls-rprx-vision', + uuid: '11111111-1111-1111-1111-111111111111', + subId: 'subid123', + enable: true, +} as unknown as ClientRecord; + +function savedFlow(save: ReturnType): unknown { + return (save.mock.calls[0][0] as Record).flow; +} + +describe('ClientFormModal — Vision flow preservation', () => { + it('keeps xtls-rprx-vision with a stable Reality inbound', async () => { + const qc = makeQC(); + const save = vi.fn().mockResolvedValue({ success: true }); + render( + + + {}} /> + + , + ); + fireEvent.click(await screen.findByRole('button', { name: /save/i })); + await waitFor(() => expect(save).toHaveBeenCalled()); + expect(savedFlow(save)).toBe('xtls-rprx-vision'); + }); + + it('does not drop a selected Vision flow while the inbound options momentarily reload', async () => { + const qc = makeQC(); + const save = vi.fn().mockResolvedValue({ success: true }); + const tree = (inbounds: InboundOption[]) => ( + + + {}} /> + + + ); + // Options loaded -> reloading (inboundOptionsQuery.data ?? [] === []) -> loaded again. + const { rerender } = render(tree([REALITY_INBOUND])); + await screen.findByRole('button', { name: /save/i }); + rerender(tree([])); + rerender(tree([REALITY_INBOUND])); + + fireEvent.click(await screen.findByRole('button', { name: /save/i })); + await waitFor(() => expect(save).toHaveBeenCalled()); + expect(savedFlow(save)).toBe('xtls-rprx-vision'); + }); +});