From 9e117bbdd3f7724a5a95f9a0c569237ee86a284f Mon Sep 17 00:00:00 2001 From: Blazethan Date: Tue, 21 Jul 2026 21:58:25 +0800 Subject: [PATCH] fix(clients): keep VLESS xtls-rprx-vision flow when inbound options reload (#5971) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client form cleared the `flow` field whenever `showFlow` was false, but `showFlow` is derived from the inbound options list, which is transiently empty while the options query (re)loads (`inboundOptionsQuery.data ?? []`). During that window `showFlow` is a false negative, so the effect silently dropped a valid `xtls-rprx-vision` flow the user had picked for a Reality/TLS inbound and never restored it — the client was then saved with an empty flow and could not connect with XTLS Vision. Guard the clear so it only runs once the inbound options are actually available. Adds a regression test that reproduces the drop across an options reload. Co-authored-by: Claude Opus 4.8 (1M context) --- .../src/pages/clients/ClientFormModal.tsx | 9 ++- frontend/src/test/client-form-flow.test.tsx | 70 +++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 frontend/src/test/client-form-flow.test.tsx 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'); + }); +});