From 0a1231d70ca5ae937cae3e1f3f7778e62eabe3b3 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Wed, 15 Jul 2026 02:55:52 +0200 Subject: [PATCH] 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. --- .../lib/xray/forms/fields/SniffingField.tsx | 6 +-- .../src/test/outbound-form-modal.test.tsx | 41 ++++++++++++++++++- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/frontend/src/lib/xray/forms/fields/SniffingField.tsx b/frontend/src/lib/xray/forms/fields/SniffingField.tsx index aee7308a0..45ce5ad26 100644 --- a/frontend/src/lib/xray/forms/fields/SniffingField.tsx +++ b/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; diff --git a/frontend/src/test/outbound-form-modal.test.tsx b/frontend/src/test/outbound-form-modal.test.tsx index 6c45a1e85..0405902d3 100644 --- a/frontend/src/test/outbound-form-modal.test.tsx +++ b/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( + {}} + 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'); + }); });