diff --git a/frontend/src/components/FinalMaskForm.tsx b/frontend/src/components/FinalMaskForm.tsx index bd2840b6..079aceb6 100644 --- a/frontend/src/components/FinalMaskForm.tsx +++ b/frontend/src/components/FinalMaskForm.tsx @@ -7,11 +7,14 @@ import { RandomUtil } from '@/utils'; import { OutboundProtocols } from '@/schemas/primitives'; // Pattern A FinalMaskForm. Renders a Fragment of Form.Items at absolute -// paths under `name`; the parent modal owns the Form instance and the -// surrounding layout. The legacy class-coupled component (which mutated -// `stream.finalmask.*` via .addTcpMask/.delTcpMask methods) is gone — all -// state lives in the parent form values, accessed via the `form` and -// `name` props. +// paths under `name`; the parent modal owns the Form instance. +// +// Naming convention inside Form.List: AntD prefixes Form.Item `name` +// with the Form.List's own `name`. So Form.Items inside the render +// prop use RELATIVE paths (e.g. `[field.name, 'type']`). Nested +// Form.Lists also use relative names. Using absolute paths here would +// double up the prefix and silently route reads/writes to the wrong +// storage path. export interface FinalMaskFormProps { name: NamePath; @@ -97,7 +100,7 @@ export default function FinalMaskForm({ name, network, protocol, form }: FinalMa return ( <> {showTcp && } - {showUdp && } + {showUdp && } {showQuic && ( <> @@ -133,10 +136,10 @@ function TcpMasksList({ base, form }: { base: (string | number)[]; form: FormIns {fields.map((field, mIdx) => ( remove(field.name)} /> ))} @@ -147,15 +150,18 @@ function TcpMasksList({ base, form }: { base: (string | number)[]; form: FormIns } function TcpMaskItem({ - base, index, displayIndex, form, onRemove, + fieldName, displayIndex, form, listPath, onRemove, }: { - base: (string | number)[]; - index: number; + fieldName: number; displayIndex: number; form: FormInstance; + listPath: (string | number)[]; onRemove: () => void; }) { - const path = [...base, 'tcp', index]; + // Absolute path for setFieldValue side effects (resetting settings on + // type change). All Form.Item `name=` use RELATIVE paths within the + // outer Form.List context. + const absolutePath = [...listPath, fieldName]; return (
@@ -164,9 +170,11 @@ function TcpMaskItem({ - + - + - + - + @@ -210,21 +220,27 @@ function TcpMaskItem({ if (type === 'sudoku') { return ( <> - - - - - + + + + + - + ); } if (type === 'header-custom') { - return ; + return ( + + ); } return null; }} @@ -233,11 +249,29 @@ function TcpMaskItem({ ); } -function HeaderCustomGroups({ base, form }: { base: (string | number)[]; form: FormInstance }) { +// Walks a deep object path safely. Used inside shouldUpdate which gets +// the whole form values blob; we need to compare a deep field across +// prev/curr without crashing on missing intermediates. +function getDeep(obj: unknown, path: (string | number)[]): unknown { + let cur: unknown = obj; + for (const key of path) { + if (cur == null || typeof cur !== 'object') return undefined; + cur = (cur as Record)[key]; + } + return cur; +} + +function HeaderCustomGroups({ + tcpFieldName, form, absoluteSettingsPath, +}: { + tcpFieldName: number; + form: FormInstance; + absoluteSettingsPath: (string | number)[]; +}) { return ( <> {(['clients', 'servers'] as const).map((groupKey) => ( - + {(groups, { add: addGroup, remove: removeGroup }) => ( <> @@ -254,7 +288,7 @@ function HeaderCustomGroups({ base, form }: { base: (string | number)[]; form: F {groupKey === 'clients' ? 'Clients' : 'Servers'} Group {gi + 1} removeGroup(group.name)} /> - + {(items, { add: addItem, remove: removeItem }) => ( <> @@ -267,8 +301,9 @@ function HeaderCustomGroups({ base, form }: { base: (string | number)[]; form: F {items.map((item) => ( removeItem(item.name)} /> @@ -287,8 +322,8 @@ function HeaderCustomGroups({ base, form }: { base: (string | number)[]; form: F } function UdpMasksList({ - base, form, isHysteria, -}: { base: (string | number)[]; form: FormInstance; isHysteria: boolean }) { + base, form, isHysteria, network, +}: { base: (string | number)[]; form: FormInstance; isHysteria: boolean; network: string }) { return ( {(fields, { add, remove }) => ( @@ -307,11 +342,12 @@ function UdpMasksList({ {fields.map((field, mIdx) => ( remove(field.name)} /> ))} @@ -322,24 +358,23 @@ function UdpMasksList({ } function UdpMaskItem({ - base, index, displayIndex, form, isHysteria, onRemove, + fieldName, displayIndex, form, listPath, isHysteria, network, onRemove, }: { - base: (string | number)[]; - index: number; + fieldName: number; displayIndex: number; form: FormInstance; + listPath: (string | number)[]; isHysteria: boolean; + network: string; onRemove: () => void; }) { - const path = [...base, 'udp', index]; - const type = Form.useWatch([...path, 'type'], form) as string | undefined; - const network = Form.useWatch([...base.slice(0, -1), 'network'], form) as string | undefined; + const absolutePath = [...listPath, fieldName]; const onTypeChange = (v: string) => { - form.setFieldValue([...path, 'settings'], defaultUdpMaskSettings(v)); + form.setFieldValue([...absolutePath, 'settings'], defaultUdpMaskSettings(v)); if (network === 'kcp') { - const kcpPath = [...base.slice(0, -1), 'kcpSettings', 'mtu']; - form.setFieldValue(kcpPath, v === 'xdns' ? 900 : 1350); + const kcpMtuPath = [...listPath.slice(0, -1), 'kcpSettings', 'mtu']; + form.setFieldValue(kcpMtuPath, v === 'xdns' ? 900 : 1350); } }; @@ -367,55 +402,85 @@ function UdpMaskItem({ - + - - )} - - {type === 'header-dns' && ( - - - - )} - - {type === 'xdns' && ( - - - - - - - - )} - - {type === 'header-custom' && ( - - )} - - {type === 'noise' && ( - - )} + getDeep(prev, [...absolutePath, 'type']) !== getDeep(curr, [...absolutePath, 'type'])} + > + {({ getFieldValue }) => { + const type = getFieldValue([...absolutePath, 'type']) as string | undefined; + if (type === 'mkcp-aes128gcm' || type === 'salamander') { + return ( + + + + ); + } + if (type === 'header-dns') { + return ( + + + + ); + } + if (type === 'xdns') { + return ( + + + + + + + + ); + } + if (type === 'header-custom') { + return ( + + ); + } + if (type === 'noise') { + return ( + + ); + } + return null; + }} +
); } -function UdpHeaderCustom({ base, form }: { base: (string | number)[]; form: FormInstance }) { +function UdpHeaderCustom({ + udpFieldName, form, absoluteSettingsPath, +}: { + udpFieldName: number; + form: FormInstance; + absoluteSettingsPath: (string | number)[]; +}) { return ( <> {(['client', 'server'] as const).map((groupKey) => ( - + {(items, { add, remove }) => ( <> @@ -433,8 +498,9 @@ function UdpHeaderCustom({ base, form }: { base: (string | number)[]; form: Form remove(item.name)} /> remove(item.name)} /> @@ -447,13 +513,19 @@ function UdpHeaderCustom({ base, form }: { base: (string | number)[]; form: Form ); } -function NoiseItems({ base, form }: { base: (string | number)[]; form: FormInstance }) { +function NoiseItems({ + udpFieldName, form, absoluteSettingsPath, +}: { + udpFieldName: number; + form: FormInstance; + absoluteSettingsPath: (string | number)[]; +}) { return ( <> - + - + {(items, { add, remove }) => ( <> @@ -471,8 +543,9 @@ function NoiseItems({ base, form }: { base: (string | number)[]; form: FormInsta remove(item.name)} /> remove(item.name)} /> @@ -486,28 +559,28 @@ function NoiseItems({ base, form }: { base: (string | number)[]; form: FormInsta } function ItemEditor({ - base, form, delayMode, onRemove: _onRemove, + fieldName, form, absoluteItemPath, delayMode, onRemove: _onRemove, }: { - base: (string | number)[]; + fieldName: number; form: FormInstance; + absoluteItemPath: (string | number)[]; delayMode?: 'number' | 'string'; onRemove?: () => void; }) { - const type = Form.useWatch([...base, 'type'], form) as string | undefined; - const onTypeChange = (v: string) => { - if (v === 'base64') form.setFieldValue([...base, 'packet'], RandomUtil.randomBase64()); - else if (v === 'array') { - form.setFieldValue([...base, 'rand'], delayMode === 'string' ? '1-8192' : 0); - form.setFieldValue([...base, 'packet'], []); + if (v === 'base64') { + form.setFieldValue([...absoluteItemPath, 'packet'], RandomUtil.randomBase64()); + } else if (v === 'array') { + form.setFieldValue([...absoluteItemPath, 'rand'], delayMode === 'string' ? '1-8192' : 0); + form.setFieldValue([...absoluteItemPath, 'packet'], []); } else { - form.setFieldValue([...base, 'packet'], ''); + form.setFieldValue([...absoluteItemPath, 'packet'], ''); } }; return ( <> - + )} - {type === 'array' ? ( - <> - - {delayMode === 'string' ? ( - - ) : ( - - )} - - - - - - ) : type === 'base64' ? ( - - - - + getDeep(prev, [...absoluteItemPath, 'type']) !== getDeep(curr, [...absoluteItemPath, 'type'])} + > + {({ getFieldValue }) => { + const type = getFieldValue([...absoluteItemPath, 'type']) as string | undefined; + if (type === 'array') { + return ( + <> + + {delayMode === 'string' ? ( + + ) : ( + + )} + + + + + + ); + } + if (type === 'base64') { + return ( + + + + + +