From 60350f93e7e692e8295ccaada209201991351f5b Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Tue, 26 May 2026 16:00:42 +0200 Subject: [PATCH] fix(frontend): Phase 2 Inbound form reactivity bugs (B1-B9, consolidated) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A run of resets dropped the per-bug commits 1401d833 / 5b1ae450 / 5bce0dc5 / 4007eec7. Re-landing all fixes against the same files in one commit to avoid another rebase-style drop. B1 — Transmission Select / External Proxy + Sockopt switches didn't react after click. AntD 6.4.3 Form.useWatch on nested paths doesn't re-fire reliably after `setFieldValue('streamSettings', cleaned)` on the parent. Bound Transmission via `name={['streamSettings', 'network']}` and wrapped the two switches in `` blocks that read state via getFieldValue. B2 — Security regressed from `Radio.Group buttonStyle="solid"` to a Select dropdown, and disable state didn't refresh because tlsAllowed/ realityAllowed were derived at the top of the component. Restored Radio.Button group and moved canEnableTls/canEnableReality evaluation inside the shouldUpdate render prop. B3 — Advanced tab "All" sub-tab was missing. Added it as the first item with a new AdvancedAllEditor that round-trips top-level fields + the three nested slices on edit. B4 — Advanced tab title/subtitle and per-section help text were gone. Wrapped the Tabs in the existing `.advanced-shell` / `.advanced-panel` structure and restored the `.advanced-editor-meta` help under each sub-tab using existing i18n keys. B5 — TLS / Reality sub-forms didn't render when selecting tls or reality on the Security tab. The `{security === 'tls' && ...}` and `{security === 'reality' && ...}` conditionals used a stale top-level useWatch value. Wrapped both in blocks that read `security` via getFieldValue. B6 — Advanced JSON editors stale after Stream/Sniffing changes. The editors seeded text via lazy useState and AntD Tabs renders all panes upfront, so the Advanced tab was already mounted with stale data. Both AdvancedSliceEditor and AdvancedAllEditor now subscribe via Form.useWatch and re-sync the text buffer when the watched JSON differs from a lastEmitRef (the serialization at the moment of our own last accepted write). User typing doesn't trigger re-sync because setFieldValue updates lastEmitRef too. (A prior attempt added `destroyOnHidden` to the outer Tabs but broke conditional tab items when the unmounted Form.Item for `protocol` lost its value — abandoned in favor of useWatch reactivity.) B7 — HeaderMapEditor + button did nothing. addRow() appended a blank {name:'', value:''} row, but commit() filtered it via rowsToMap before reaching the form, so AntD saw no change and didn't re-render. The editor now keeps a local rows state so blank rows survive during editing; only filled rows are emitted to onChange. B9 — Sniffing destOverride defaults (HTTP/TLS/QUIC/FAKEDNS) were not pre-checked on a fresh Add Inbound. buildAddModeValues() seeded sniffing: {} which left destOverride undefined. Now seeds with SniffingSchema.parse({}) so the Zod defaults populate. --- frontend/src/components/HeaderMapEditor.tsx | 25 +- .../src/pages/inbounds/InboundFormModal.tsx | 532 ++++++++++++------ 2 files changed, 388 insertions(+), 169 deletions(-) diff --git a/frontend/src/components/HeaderMapEditor.tsx b/frontend/src/components/HeaderMapEditor.tsx index f199bbd8..c630c851 100644 --- a/frontend/src/components/HeaderMapEditor.tsx +++ b/frontend/src/components/HeaderMapEditor.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Button, Input, Space } from 'antd'; import { MinusOutlined, PlusOutlined } from '@ant-design/icons'; @@ -74,10 +74,29 @@ function rowsToMap(rows: HeaderRow[], mode: HeaderMapMode): Record mapToRows(value), [value]); + // Local state holds rows including blanks. Without it, addRow() would + // append a {name:'', value:''} that rowsToMap immediately filters out + // before reaching the form, so the new row would never reach UI. The + // form-bound map only sees rows with non-empty names; blank rows live + // here until the user fills them in. + const [rows, setRows] = useState(() => mapToRows(value)); + const lastEmittedRef = useRef(JSON.stringify(rowsToMap(rows, mode))); + + // Re-sync local rows when the form value changes from outside (modal + // re-open with edit data, JSON tab edits, etc.) but not when it's our + // own emission echoing back. + useEffect(() => { + const incoming = JSON.stringify(value ?? {}); + if (incoming === lastEmittedRef.current) return; + setRows(mapToRows(value)); + lastEmittedRef.current = incoming; + }, [value]); function commit(next: HeaderRow[]) { - onChange?.(rowsToMap(next, mode)); + setRows(next); + const map = rowsToMap(next, mode); + lastEmittedRef.current = JSON.stringify(map); + onChange?.(map); } function setRow(index: number, patch: Partial) { diff --git a/frontend/src/pages/inbounds/InboundFormModal.tsx b/frontend/src/pages/inbounds/InboundFormModal.tsx index bca358e9..60f09c60 100644 --- a/frontend/src/pages/inbounds/InboundFormModal.tsx +++ b/frontend/src/pages/inbounds/InboundFormModal.tsx @@ -63,6 +63,7 @@ import { import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt'; import { TlsStreamSettingsSchema } from '@/schemas/protocols/security/tls'; import { RealityStreamSettingsSchema } from '@/schemas/protocols/security/reality'; +import { SniffingSchema } from '@/schemas/primitives/sniffing'; import DateTimePicker from '@/components/DateTimePicker'; import FinalMaskForm from '@/components/FinalMaskForm'; import HeaderMapEditor from '@/components/HeaderMapEditor'; @@ -100,9 +101,26 @@ function AdvancedSliceEditor({ minHeight?: string; maxHeight?: string; }) { - const [text, setText] = useState(() => - JSON.stringify(form.getFieldValue(path) ?? {}, null, 2), - ); + // The editor keeps a local text buffer so partial / invalid JSON typing + // doesn't clobber the form. lastEmitRef tracks the serialized form value + // at the moment we last accepted a write — if useWatch later fires with + // a different value than that, the form was changed from elsewhere + // (Stream tab toggle, sibling JSON tab edit), and we re-sync. + const watched = Form.useWatch(path, form); + const lastEmitRef = useRef(''); + const [text, setText] = useState(() => { + const initial = JSON.stringify(form.getFieldValue(path) ?? {}, null, 2); + lastEmitRef.current = initial; + return initial; + }); + + useEffect(() => { + const formStr = JSON.stringify(watched ?? {}, null, 2); + if (formStr === lastEmitRef.current) return; + setText(formStr); + lastEmitRef.current = formStr; + }, [watched]); + return ( { setText(next); try { - form.setFieldValue(path, JSON.parse(next)); + const parsed = JSON.parse(next); + form.setFieldValue(path, parsed); + lastEmitRef.current = JSON.stringify(parsed, null, 2); } catch { - + // invalid JSON; keep buffer, don't push to form } }} /> ); } +// The "All" editor shows the full inbound JSON in one editor: top-level +// connection fields plus the three nested sub-objects (settings, +// streamSettings, sniffing). Edits round-trip back to the form's slices, +// mirroring the legacy modal's setAdvancedAllValue behavior. Reactivity +// works the same way as AdvancedSliceEditor: useWatch on the slices we +// care about, lastEmitRef as the "we wrote this" guard. +function AdvancedAllEditor({ + form, + streamEnabled, +}: { + form: FormInstance; + streamEnabled: boolean; +}) { + const wListen = Form.useWatch('listen', form); + const wPort = Form.useWatch('port', form); + const wProtocol = Form.useWatch('protocol', form); + const wTag = Form.useWatch('tag', form); + const wSettings = Form.useWatch('settings', form); + const wSniffing = Form.useWatch('sniffing', form); + const wStream = Form.useWatch('streamSettings', form); + + const serialize = () => { + const out: Record = { + listen: wListen ?? '', + port: wPort ?? 0, + protocol: wProtocol ?? '', + tag: wTag ?? '', + settings: wSettings ?? {}, + sniffing: wSniffing ?? {}, + }; + if (streamEnabled) out.streamSettings = wStream ?? {}; + return JSON.stringify(out, null, 2); + }; + + const lastEmitRef = useRef(''); + const [text, setText] = useState(() => { + const initial = serialize(); + lastEmitRef.current = initial; + return initial; + }); + + useEffect(() => { + const formStr = serialize(); + if (formStr === lastEmitRef.current) return; + setText(formStr); + lastEmitRef.current = formStr; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [wListen, wPort, wProtocol, wTag, wSettings, wSniffing, wStream, streamEnabled]); + + return ( + { + setText(next); + let parsed: Record; + try { + parsed = JSON.parse(next) as Record; + } catch { + return; + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return; + if (typeof parsed.listen === 'string') form.setFieldValue('listen', parsed.listen); + if (typeof parsed.port === 'number' && Number.isFinite(parsed.port)) { + form.setFieldValue('port', parsed.port); + } + if (typeof parsed.protocol === 'string') form.setFieldValue('protocol', parsed.protocol); + if (typeof parsed.tag === 'string') form.setFieldValue('tag', parsed.tag); + if (parsed.settings && typeof parsed.settings === 'object') { + form.setFieldValue('settings', parsed.settings); + } + if (parsed.sniffing && typeof parsed.sniffing === 'object') { + form.setFieldValue('sniffing', parsed.sniffing); + } + if (streamEnabled && parsed.streamSettings && typeof parsed.streamSettings === 'object') { + form.setFieldValue('streamSettings', parsed.streamSettings); + } + lastEmitRef.current = next; + }} + /> + ); +} + const PROTOCOL_OPTIONS = Object.values(Protocols).map((p) => ({ value: p, label: p })); const TRAFFIC_RESETS = ['never', 'hourly', 'daily', 'weekly', 'monthly'] as const; const NODE_ELIGIBLE_PROTOCOLS = new Set([ @@ -147,7 +251,7 @@ function buildAddModeValues(): InboundFormValues { protocol: 'vless', settings, streamSettings: { network: 'tcp', security: 'none' }, - sniffing: {}, + sniffing: SniffingSchema.parse({}), port: RandomUtil.randomInteger(10000, 60000), listen: '', tag: '', @@ -186,8 +290,6 @@ export default function InboundFormModal({ const network = Form.useWatch(['streamSettings', 'network'], form) ?? ''; const security = Form.useWatch(['streamSettings', 'security'], form) ?? 'none'; const streamEnabled = canEnableStream({ protocol }); - const tlsAllowed = canEnableTls({ protocol, streamSettings: { network, security } }); - const realityAllowed = canEnableReality({ protocol, streamSettings: { network, security } }); const isFallbackHost = (protocol === Protocols.VLESS || protocol === Protocols.TROJAN) && network === 'tcp' @@ -385,10 +487,6 @@ export default function InboundFormModal({ const xhttpSessionPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'sessionPlacement'], form); const xhttpSeqPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'seqPlacement'], form); const xhttpUplinkPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'uplinkDataPlacement'], form); - const externalProxyArr = Form.useWatch(['streamSettings', 'externalProxy'], form); - const externalProxyOn = Array.isArray(externalProxyArr) && externalProxyArr.length > 0; - const sockoptValue = Form.useWatch(['streamSettings', 'sockopt'], form); - const sockoptOn = !!sockoptValue && typeof sockoptValue === 'object' && Object.keys(sockoptValue as object).length > 0; const toggleExternalProxy = (on: boolean) => { if (on) { @@ -1205,9 +1303,8 @@ export default function InboundFormModal({ const streamTab = ( <> {protocol !== Protocols.HYSTERIA && ( - + - {t('pages.inbounds.same')} - {t('none')} - TLS - + {on && ( + + {(fields, { add, remove }) => ( + <> + + - - - - - - - - - - remove(field.name)}> - - - - - prev.streamSettings?.externalProxy?.[field.name]?.forceTls - !== curr.streamSettings?.externalProxy?.[field.name]?.forceTls - } - > - {({ getFieldValue }) => { - const ft = getFieldValue([ - 'streamSettings', 'externalProxy', field.name, 'forceTls', - ]); - if (ft !== 'tls') return null; - return ( - - - + + {fields.map((field) => ( +
+ + + + + + + + + + + + + + remove(field.name)}> + + + + + prev.streamSettings?.externalProxy?.[field.name]?.forceTls + !== curr.streamSettings?.externalProxy?.[field.name]?.forceTls + } + > + {({ getFieldValue }) => { + const ft = getFieldValue([ + 'streamSettings', 'externalProxy', field.name, 'forceTls', + ]); + if (ft !== 'tls') return null; + return ( + + + + + + + + + + + + ); + }} - - - - - - - - ); - }} - -
- ))} -
+ + ))} +
+ + )} +
+ )} - )} - - )} - - - + ); + }} - {sockoptOn && ( - <> + + { + const a = (prev.streamSettings as { sockopt?: object } | undefined)?.sockopt; + const b = (curr.streamSettings as { sockopt?: object } | undefined)?.sockopt; + return !!a !== !!b; + }} + > + {({ getFieldValue }) => { + const sock = getFieldValue(['streamSettings', 'sockopt']); + const on = !!sock && typeof sock === 'object' && Object.keys(sock).length > 0; + return ( + <> + + + + {on && ( + <> @@ -2021,8 +2148,12 @@ export default function InboundFormModal({ X-Client-IP - - )} + + )} + + ); + }} +
prev.streamSettings?.security !== curr.streamSettings?.security + || prev.streamSettings?.network !== curr.streamSettings?.network + || prev.protocol !== curr.protocol } > {({ getFieldValue }) => { const sec = getFieldValue(['streamSettings', 'security']) ?? 'none'; + const net = getFieldValue(['streamSettings', 'network']) ?? ''; + const proto = getFieldValue('protocol') ?? ''; + const tlsOk = canEnableTls({ protocol: proto, streamSettings: { network: net, security: sec } }); + const realityOk = canEnableReality({ protocol: proto, streamSettings: { network: net, security: sec } }); return ( - + none + tls + {realityOk && reality} + ); }}
- {security === 'tls' && ( - <> - + + prev.streamSettings?.security !== curr.streamSettings?.security + } + > + {({ getFieldValue }) => { + const sec = getFieldValue(['streamSettings', 'security']); + if (sec !== 'tls') return null; + return ( + <> + @@ -2299,11 +2445,22 @@ export default function InboundFormModal({ - - )} + + ); + }} + - {security === 'reality' && ( - <> + + prev.streamSettings?.security !== curr.streamSettings?.security + } + > + {({ getFieldValue }) => { + const sec = getFieldValue(['streamSettings', 'security']); + if (sec !== 'reality') return null; + return ( + <> Clear - - )} + + ); + }} + ); const advancedTab = ( - - ), - }, - ...(streamEnabled - ? [{ - key: 'stream', - label: t('pages.inbounds.advanced.stream'), - children: ( - - ), - }] - : []), - { - key: 'sniffing', - label: t('pages.inbounds.advanced.sniffing'), - children: ( - - ), - }, - ]} - /> +
+
+
+
+
{t('pages.inbounds.advanced.title')}
+
{t('pages.inbounds.advanced.subtitle')}
+
+
+ +
+ {t('pages.inbounds.advanced.allHelp')} +
+ + + ), + }, + { + key: 'settings', + label: t('pages.inbounds.advanced.settings'), + children: ( + <> +
+ {t('pages.inbounds.advanced.settingsHelp')}{' '} + {'{ settings: { ... } }'}. +
+ + + ), + }, + ...(streamEnabled + ? [{ + key: 'stream', + label: t('pages.inbounds.advanced.stream'), + children: ( + <> +
+ {t('pages.inbounds.advanced.streamHelp')}{' '} + {'{ streamSettings: { ... } }'}. +
+ + + ), + }] + : []), + { + key: 'sniffing', + label: t('pages.inbounds.advanced.sniffing'), + children: ( + <> +
+ {t('pages.inbounds.advanced.sniffingHelp')}{' '} + {'{ sniffing: { ... } }'}. +
+ + + ), + }, + ]} + /> +
+
); const sniffingTab = (