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.
This commit is contained in:
MHSanaei
2026-07-15 02:55:52 +02:00
parent 83de6e75e0
commit 0a1231d70c
2 changed files with 42 additions and 5 deletions
@@ -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
View File
@@ -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');
});
});