Kaynağa Gözat

fix(ui): stop the sniffing form island from clobbering unrendered fields

antd's Form.useWatch only reports registered fields, so while the
sniffing toggle was off the island emitted { enabled: false } upward and
replaced the full Sniffing object in form state. Saving a VLESS reverse
outbound then crashed in sniffingToWire on the missing ipsExcluded
array; the loopback outbound and the inbound sniffing tab shared the
same hole. Watch the store with preserve: true so unrendered fields
keep their values, and seed a missing value from the schema defaults
instead of an empty cast.
MHSanaei 1 gün önce
ebeveyn
işleme
0a1231d70c

+ 3 - 3
frontend/src/lib/xray/forms/fields/SniffingField.tsx

@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
 import { Form } from 'antd';
 
 import SniffingFields from '@/lib/xray/forms/SniffingFields';
-import type { Sniffing } from '@/schemas/primitives/sniffing';
+import { SniffingSchema, type Sniffing } from '@/schemas/primitives/sniffing';
 
 interface SniffingFieldProps {
   value?: Sniffing;
@@ -12,12 +12,12 @@ interface SniffingFieldProps {
 
 export default function SniffingField({ value, onChange, enableLabel }: SniffingFieldProps) {
   const [form] = Form.useForm();
-  const [initial] = useState(() => value ?? ({} as Sniffing));
+  const [initial] = useState(() => value ?? SniffingSchema.parse({}));
   const onChangeRef = useRef(onChange);
   onChangeRef.current = onChange;
   const lastEmitted = useRef(JSON.stringify(initial));
 
-  const sniffing = Form.useWatch('sniffing', form) as Sniffing | undefined;
+  const sniffing = Form.useWatch('sniffing', { form, preserve: true }) as Sniffing | undefined;
 
   useEffect(() => {
     if (sniffing === undefined) return;

+ 39 - 2
frontend/src/test/outbound-form-modal.test.tsx

@@ -1,5 +1,5 @@
-import { describe, it, expect } from 'vitest';
-import { act } from '@testing-library/react';
+import { describe, it, expect, vi } from 'vitest';
+import { act, fireEvent } from '@testing-library/react';
 
 import OutboundFormModal from '@/pages/xray/outbounds/OutboundFormModal';
 import {
@@ -54,4 +54,41 @@ describe('OutboundFormModal', () => {
       expect(labelsByProto.wireguard).not.toContain('Encryption');
     }
   }, 30000); // iterates every protocol, re-rendering a heavy modal each time — slow on CI runners
+
+  it('saves a vless reverse outbound while reverse sniffing stays disabled', async () => {
+    const onConfirm = vi.fn();
+    renderWithProviders(
+      <OutboundFormModal
+        open
+        outbound={{
+          protocol: 'vless',
+          tag: 'reverse-out',
+          settings: {
+            vnext: [{
+              address: 'example.com',
+              port: 443,
+              users: [{ id: 'c9f0c2d0-0000-4000-8000-000000000000', encryption: 'none' }],
+            }],
+            reverse: { tag: 'r1', sniffing: {} },
+          },
+          streamSettings: { network: 'tcp', security: 'none' },
+        }}
+        existingTags={[]}
+        onClose={() => {}}
+        onConfirm={onConfirm}
+      />,
+    );
+    await act(async () => { await new Promise((r) => setTimeout(r, 0)); });
+
+    const ok = document.querySelector('.ant-modal-footer .ant-btn-primary') as HTMLElement;
+    expect(ok).toBeTruthy();
+    await act(async () => { fireEvent.click(ok); });
+    await act(async () => { await new Promise((r) => setTimeout(r, 0)); });
+
+    expect(onConfirm).toHaveBeenCalledTimes(1);
+    const payload = onConfirm.mock.calls[0][0] as {
+      settings: { reverse?: { tag?: string } };
+    };
+    expect(payload.settings.reverse?.tag).toBe('r1');
+  });
 });