Sfoglia il codice sorgente

fix(clients): keep VLESS xtls-rprx-vision flow when inbound options reload (#5971)

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) <[email protected]>
Blazethan 15 ore fa
parent
commit
9e117bbdd3

+ 7 - 2
frontend/src/pages/clients/ClientFormModal.tsx

@@ -373,10 +373,15 @@ export default function ClientFormModal({
   }
   }
 
 
   useEffect(() => {
   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', '');
       methods.setValue('flow', '');
     }
     }
-  }, [showFlow, flow, methods]);
+  }, [inbounds, showFlow, flow, methods]);
 
 
   useEffect(() => {
   useEffect(() => {
     if (!showReverseTag && reverseTag) {
     if (!showReverseTag && reverseTag) {

+ 70 - 0
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<typeof vi.fn>): unknown {
+  return (save.mock.calls[0][0] as Record<string, unknown>).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(
+      <ThemeProvider>
+        <QueryClientProvider client={qc}>
+          <ClientFormModal open mode="edit" client={CLIENT} inbounds={[REALITY_INBOUND]} attachedIds={[4]} save={save} onOpenChange={() => {}} />
+        </QueryClientProvider>
+      </ThemeProvider>,
+    );
+    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[]) => (
+      <ThemeProvider>
+        <QueryClientProvider client={qc}>
+          <ClientFormModal open mode="edit" client={CLIENT} inbounds={inbounds} attachedIds={[4]} save={save} onOpenChange={() => {}} />
+        </QueryClientProvider>
+      </ThemeProvider>
+    );
+    // 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');
+  });
+});