Pārlūkot izejas kodu

fix(frontend): FinalMaskForm relative paths + network-switch defaults (B13/B14)

B13 — FinalMaskForm used absolute paths like
['streamSettings', 'finalmask', 'tcp', 0, 'type'] for Form.Item names
inside Form.List render props. AntD's Form.List prefixes Form.Item
names with the list's own name, so the actual storage path became
['streamSettings', 'finalmask', 'tcp', 'streamSettings', 'finalmask',
'tcp', 0, 'type'] — total nonsense. Symptoms: Type Select didn't show
the 'fragment' default after add(), and the sub-form for the picked
type never rendered (Fragment/Sudoku/HeaderCustom).

Rewrote FinalMaskForm to use RELATIVE names inside every Form.List
context (TCP/UDP outer list + nested clients/servers/noise inner
lists). Added a `listPath` prop on the items so the shouldUpdate
guard and the side-effect setFieldValue calls (resetting `settings`
when type changes) can still address the absolute path; the
displayed Form.Items use the relative form (`[fieldName, 'type']`).

Replaced top-level Form.useWatch on nested paths with
<Form.Item shouldUpdate> blocks reading via getFieldValue, same
pattern as the earlier B5 fix — Form.useWatch on paths inside
Form.List doesn't re-fire reliably in AntD 6.4.3.

B14 — Switching network (KCP, WS, gRPC, XHTTP, ...) seeded the
new XSettings blob as `{}` so every field showed as empty. The
legacy `newStreamSlice` populated mtu=1350, tti=20, etc. Restored
those defaults in onNetworkChange and seeded the initial
tcpSettings.header in buildAddModeValues so even the default TCP
state shows the HTTP-camouflage Switch in the correct off state
instead of an undefined header object.
MHSanaei 1 dienu atpakaļ
vecāks
revīzija
fbdc6cdf91

+ 218 - 131
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 && <TcpMasksList base={base} form={form} />}
-      {showUdp && <UdpMasksList base={base} form={form} isHysteria={isHysteria} />}
+      {showUdp && <UdpMasksList base={base} form={form} isHysteria={isHysteria} network={network} />}
       {showQuic && (
         <>
           <Form.Item label="QUIC Params" name={[...base, 'enableQuicParams']} valuePropName="checked">
@@ -133,10 +136,10 @@ function TcpMasksList({ base, form }: { base: (string | number)[]; form: FormIns
           {fields.map((field, mIdx) => (
             <TcpMaskItem
               key={field.key}
-              base={base}
-              index={field.name}
+              fieldName={field.name}
               displayIndex={mIdx + 1}
               form={form}
+              listPath={[...base, 'tcp']}
               onRemove={() => 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 (
     <div>
@@ -164,9 +170,11 @@ function TcpMaskItem({
         <DeleteOutlined className="danger-icon" onClick={onRemove} />
       </Divider>
 
-      <Form.Item label="Type" name={[...path, 'type']}>
+      <Form.Item label="Type" name={[fieldName, 'type']}>
         <Select
-          onChange={(v) => form.setFieldValue([...path, 'settings'], defaultTcpMaskSettings(v))}
+          onChange={(v) =>
+            form.setFieldValue([...absolutePath, 'settings'], defaultTcpMaskSettings(v))
+          }
           options={[
             { value: 'fragment', label: 'Fragment' },
             { value: 'header-custom', label: 'Header Custom' },
@@ -177,16 +185,18 @@ function TcpMaskItem({
 
       <Form.Item
         noStyle
-        shouldUpdate={(prev, curr) =>
-          (prev as Record<string, unknown>)[String(path[0])] !== (curr as Record<string, unknown>)[String(path[0])]
-        }
+        shouldUpdate={(prev, curr) => {
+          const a = getDeep(prev, [...absolutePath, 'type']);
+          const b = getDeep(curr, [...absolutePath, 'type']);
+          return a !== b;
+        }}
       >
         {({ getFieldValue }) => {
-          const type = getFieldValue([...path, 'type']) as string | undefined;
+          const type = getFieldValue([...absolutePath, 'type']) as string | undefined;
           if (type === 'fragment') {
             return (
               <>
-                <Form.Item label="Packets" name={[...path, 'settings', 'packets']}>
+                <Form.Item label="Packets" name={[fieldName, 'settings', 'packets']}>
                   <Select
                     options={[
                       { value: 'tlshello', label: 'tlshello' },
@@ -195,13 +205,13 @@ function TcpMaskItem({
                     ]}
                   />
                 </Form.Item>
-                <Form.Item label="Length" name={[...path, 'settings', 'length']}>
+                <Form.Item label="Length" name={[fieldName, 'settings', 'length']}>
                   <Input />
                 </Form.Item>
-                <Form.Item label="Delay" name={[...path, 'settings', 'delay']}>
+                <Form.Item label="Delay" name={[fieldName, 'settings', 'delay']}>
                   <Input />
                 </Form.Item>
-                <Form.Item label="Max Split" name={[...path, 'settings', 'maxSplit']}>
+                <Form.Item label="Max Split" name={[fieldName, 'settings', 'maxSplit']}>
                   <Input />
                 </Form.Item>
               </>
@@ -210,21 +220,27 @@ function TcpMaskItem({
           if (type === 'sudoku') {
             return (
               <>
-                <Form.Item label="Password" name={[...path, 'settings', 'password']}><Input /></Form.Item>
-                <Form.Item label="ASCII" name={[...path, 'settings', 'ascii']}><Input /></Form.Item>
-                <Form.Item label="Custom Table" name={[...path, 'settings', 'customTable']}><Input /></Form.Item>
-                <Form.Item label="Custom Tables" name={[...path, 'settings', 'customTables']}><Input /></Form.Item>
-                <Form.Item label="Padding Min" name={[...path, 'settings', 'paddingMin']}>
+                <Form.Item label="Password" name={[fieldName, 'settings', 'password']}><Input /></Form.Item>
+                <Form.Item label="ASCII" name={[fieldName, 'settings', 'ascii']}><Input /></Form.Item>
+                <Form.Item label="Custom Table" name={[fieldName, 'settings', 'customTable']}><Input /></Form.Item>
+                <Form.Item label="Custom Tables" name={[fieldName, 'settings', 'customTables']}><Input /></Form.Item>
+                <Form.Item label="Padding Min" name={[fieldName, 'settings', 'paddingMin']}>
                   <InputNumber min={0} />
                 </Form.Item>
-                <Form.Item label="Padding Max" name={[...path, 'settings', 'paddingMax']}>
+                <Form.Item label="Padding Max" name={[fieldName, 'settings', 'paddingMax']}>
                   <InputNumber min={0} />
                 </Form.Item>
               </>
             );
           }
           if (type === 'header-custom') {
-            return <HeaderCustomGroups base={[...path, 'settings']} form={form} />;
+            return (
+              <HeaderCustomGroups
+                tcpFieldName={fieldName}
+                form={form}
+                absoluteSettingsPath={[...absolutePath, 'settings']}
+              />
+            );
           }
           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<string | number, unknown>)[key];
+  }
+  return cur;
+}
+
+function HeaderCustomGroups({
+  tcpFieldName, form, absoluteSettingsPath,
+}: {
+  tcpFieldName: number;
+  form: FormInstance;
+  absoluteSettingsPath: (string | number)[];
+}) {
   return (
     <>
       {(['clients', 'servers'] as const).map((groupKey) => (
-        <Form.List key={groupKey} name={[...base, groupKey]}>
+        <Form.List key={groupKey} name={[tcpFieldName, 'settings', groupKey]}>
           {(groups, { add: addGroup, remove: removeGroup }) => (
             <>
               <Form.Item label={groupKey === 'clients' ? 'Clients' : 'Servers'}>
@@ -254,7 +288,7 @@ function HeaderCustomGroups({ base, form }: { base: (string | number)[]; form: F
                     {groupKey === 'clients' ? 'Clients' : 'Servers'} Group {gi + 1}
                     <DeleteOutlined className="danger-icon" onClick={() => removeGroup(group.name)} />
                   </Divider>
-                  <Form.List name={[...base, groupKey, group.name]}>
+                  <Form.List name={[group.name]}>
                     {(items, { add: addItem, remove: removeItem }) => (
                       <>
                         <Form.Item label="Items">
@@ -267,8 +301,9 @@ function HeaderCustomGroups({ base, form }: { base: (string | number)[]; form: F
                         {items.map((item) => (
                           <ItemEditor
                             key={item.key}
-                            base={[...base, groupKey, group.name, item.name]}
+                            fieldName={item.name}
                             form={form}
+                            absoluteItemPath={[...absoluteSettingsPath, groupKey, group.name, item.name]}
                             delayMode="number"
                             onRemove={() => 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 (
     <Form.List name={[...base, 'udp']}>
       {(fields, { add, remove }) => (
@@ -307,11 +342,12 @@ function UdpMasksList({
           {fields.map((field, mIdx) => (
             <UdpMaskItem
               key={field.key}
-              base={base}
-              index={field.name}
+              fieldName={field.name}
               displayIndex={mIdx + 1}
               form={form}
+              listPath={[...base, 'udp']}
               isHysteria={isHysteria}
+              network={network}
               onRemove={() => 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({
         <DeleteOutlined className="danger-icon" onClick={onRemove} />
       </Divider>
 
-      <Form.Item label="Type" name={[...path, 'type']}>
+      <Form.Item label="Type" name={[fieldName, 'type']}>
         <Select onChange={onTypeChange} options={options} />
       </Form.Item>
 
-      {(type === 'mkcp-aes128gcm' || type === 'salamander') && (
-        <Form.Item label="Password" name={[...path, 'settings', 'password']}>
-          <Input placeholder="Obfuscation password" />
-        </Form.Item>
-      )}
-
-      {type === 'header-dns' && (
-        <Form.Item label="Domain" name={[...path, 'settings', 'domain']}>
-          <Input placeholder="e.g., www.example.com" />
-        </Form.Item>
-      )}
-
-      {type === 'xdns' && (
-        <Form.Item label="Domains" name={[...path, 'settings', 'domains']}>
-          <Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} />
-        </Form.Item>
-      )}
-
-      {type === 'xicmp' && (
-        <>
-          <Form.Item label="IP" name={[...path, 'settings', 'ip']}>
-            <Input placeholder="0.0.0.0" />
-          </Form.Item>
-          <Form.Item label="ID" name={[...path, 'settings', 'id']}>
-            <InputNumber min={0} />
-          </Form.Item>
-        </>
-      )}
-
-      {type === 'header-custom' && (
-        <UdpHeaderCustom base={[...path, 'settings']} form={form} />
-      )}
-
-      {type === 'noise' && (
-        <NoiseItems base={[...path, 'settings']} form={form} />
-      )}
+      <Form.Item
+        noStyle
+        shouldUpdate={(prev, curr) => getDeep(prev, [...absolutePath, 'type']) !== getDeep(curr, [...absolutePath, 'type'])}
+      >
+        {({ getFieldValue }) => {
+          const type = getFieldValue([...absolutePath, 'type']) as string | undefined;
+          if (type === 'mkcp-aes128gcm' || type === 'salamander') {
+            return (
+              <Form.Item label="Password" name={[fieldName, 'settings', 'password']}>
+                <Input placeholder="Obfuscation password" />
+              </Form.Item>
+            );
+          }
+          if (type === 'header-dns') {
+            return (
+              <Form.Item label="Domain" name={[fieldName, 'settings', 'domain']}>
+                <Input placeholder="e.g., www.example.com" />
+              </Form.Item>
+            );
+          }
+          if (type === 'xdns') {
+            return (
+              <Form.Item label="Domains" name={[fieldName, 'settings', 'domains']}>
+                <Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} />
+              </Form.Item>
+            );
+          }
+          if (type === 'xicmp') {
+            return (
+              <>
+                <Form.Item label="IP" name={[fieldName, 'settings', 'ip']}>
+                  <Input placeholder="0.0.0.0" />
+                </Form.Item>
+                <Form.Item label="ID" name={[fieldName, 'settings', 'id']}>
+                  <InputNumber min={0} />
+                </Form.Item>
+              </>
+            );
+          }
+          if (type === 'header-custom') {
+            return (
+              <UdpHeaderCustom
+                udpFieldName={fieldName}
+                form={form}
+                absoluteSettingsPath={[...absolutePath, 'settings']}
+              />
+            );
+          }
+          if (type === 'noise') {
+            return (
+              <NoiseItems
+                udpFieldName={fieldName}
+                form={form}
+                absoluteSettingsPath={[...absolutePath, 'settings']}
+              />
+            );
+          }
+          return null;
+        }}
+      </Form.Item>
     </div>
   );
 }
 
-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) => (
-        <Form.List key={groupKey} name={[...base, groupKey]}>
+        <Form.List key={groupKey} name={[udpFieldName, 'settings', groupKey]}>
           {(items, { add, remove }) => (
             <>
               <Form.Item label={groupKey === 'client' ? 'Client' : 'Server'}>
@@ -433,8 +498,9 @@ function UdpHeaderCustom({ base, form }: { base: (string | number)[]; form: Form
                     <DeleteOutlined className="danger-icon" onClick={() => remove(item.name)} />
                   </Divider>
                   <ItemEditor
-                    base={[...base, groupKey, item.name]}
+                    fieldName={item.name}
                     form={form}
+                    absoluteItemPath={[...absoluteSettingsPath, groupKey, item.name]}
                     onRemove={() => remove(item.name)}
                   />
                 </div>
@@ -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 (
     <>
-      <Form.Item label="Reset" name={[...base, 'reset']}>
+      <Form.Item label="Reset" name={[udpFieldName, 'settings', 'reset']}>
         <InputNumber min={0} />
       </Form.Item>
-      <Form.List name={[...base, 'noise']}>
+      <Form.List name={[udpFieldName, 'settings', 'noise']}>
         {(items, { add, remove }) => (
           <>
             <Form.Item label="Noise">
@@ -471,8 +543,9 @@ function NoiseItems({ base, form }: { base: (string | number)[]; form: FormInsta
                   <DeleteOutlined className="danger-icon" onClick={() => remove(item.name)} />
                 </Divider>
                 <ItemEditor
-                  base={[...base, 'noise', item.name]}
+                  fieldName={item.name}
                   form={form}
+                  absoluteItemPath={[...absoluteSettingsPath, 'noise', item.name]}
                   delayMode="string"
                   onRemove={() => 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 (
     <>
-      <Form.Item label="Type" name={[...base, 'type']}>
+      <Form.Item label="Type" name={[fieldName, 'type']}>
         <Select
           onChange={onTypeChange}
           options={[
@@ -520,46 +593,60 @@ function ItemEditor({
       </Form.Item>
 
       {delayMode === 'number' && (
-        <Form.Item label="Delay (ms)" name={[...base, 'delay']}>
+        <Form.Item label="Delay (ms)" name={[fieldName, 'delay']}>
           <InputNumber min={0} />
         </Form.Item>
       )}
       {delayMode === 'string' && (
-        <Form.Item label="Delay" name={[...base, 'delay']}>
+        <Form.Item label="Delay" name={[fieldName, 'delay']}>
           <Input placeholder="10-20" />
         </Form.Item>
       )}
 
-      {type === 'array' ? (
-        <>
-          <Form.Item label="Rand" name={[...base, 'rand']}>
-            {delayMode === 'string' ? (
-              <Input placeholder="0 or 1-8192" />
-            ) : (
-              <InputNumber min={0} />
-            )}
-          </Form.Item>
-          <Form.Item label="Rand Range" name={[...base, 'randRange']}>
-            <Input placeholder="0-255" />
-          </Form.Item>
-        </>
-      ) : type === 'base64' ? (
-        <Form.Item label="Packet">
-          <Input.Group compact>
-            <Form.Item name={[...base, 'packet']} noStyle>
-              <Input placeholder="binary data" style={{ width: 'calc(100% - 32px)' }} />
+      <Form.Item
+        noStyle
+        shouldUpdate={(prev, curr) => getDeep(prev, [...absoluteItemPath, 'type']) !== getDeep(curr, [...absoluteItemPath, 'type'])}
+      >
+        {({ getFieldValue }) => {
+          const type = getFieldValue([...absoluteItemPath, 'type']) as string | undefined;
+          if (type === 'array') {
+            return (
+              <>
+                <Form.Item label="Rand" name={[fieldName, 'rand']}>
+                  {delayMode === 'string' ? (
+                    <Input placeholder="0 or 1-8192" />
+                  ) : (
+                    <InputNumber min={0} />
+                  )}
+                </Form.Item>
+                <Form.Item label="Rand Range" name={[fieldName, 'randRange']}>
+                  <Input placeholder="0-255" />
+                </Form.Item>
+              </>
+            );
+          }
+          if (type === 'base64') {
+            return (
+              <Form.Item label="Packet">
+                <Input.Group compact>
+                  <Form.Item name={[fieldName, 'packet']} noStyle>
+                    <Input placeholder="binary data" style={{ width: 'calc(100% - 32px)' }} />
+                  </Form.Item>
+                  <Button
+                    icon={<ReloadOutlined />}
+                    onClick={() => form.setFieldValue([...absoluteItemPath, 'packet'], RandomUtil.randomBase64())}
+                  />
+                </Input.Group>
+              </Form.Item>
+            );
+          }
+          return (
+            <Form.Item label="Packet" name={[fieldName, 'packet']}>
+              <Input placeholder="binary data" />
             </Form.Item>
-            <Button
-              icon={<ReloadOutlined />}
-              onClick={() => form.setFieldValue([...base, 'packet'], RandomUtil.randomBase64())}
-            />
-          </Input.Group>
-        </Form.Item>
-      ) : (
-        <Form.Item label="Packet" name={[...base, 'packet']}>
-          <Input placeholder="binary data" />
-        </Form.Item>
-      )}
+          );
+        }}
+      </Form.Item>
     </>
   );
 }

+ 36 - 6
frontend/src/pages/inbounds/InboundFormModal.tsx

@@ -261,7 +261,11 @@ function buildAddModeValues(): InboundFormValues {
   return rawInboundToFormValues({
     protocol: 'vless',
     settings,
-    streamSettings: { network: 'tcp', security: 'none' },
+    streamSettings: {
+      network: 'tcp',
+      security: 'none',
+      tcpSettings: { header: { type: 'none' } },
+    },
     sniffing: SniffingSchema.parse({}),
     port: RandomUtil.randomInteger(10000, 60000),
     listen: '',
@@ -1296,10 +1300,36 @@ export default function InboundFormModal({
     </>
   );
 
-  // Switching `network` swaps which per-network key (tcpSettings, wsSettings,
-  // grpcSettings, ...) appears on the wire. We clear the previously selected
-  // network's settings blob and seed a default empty object for the new one
-  // so AntD's Form.Items aren't pointed at undefined nested paths.
+  // Switching `network` swaps which per-network key (tcpSettings,
+  // wsSettings, grpcSettings, ...) appears on the wire. Clear the old
+  // network's blob and seed the new one with the schema defaults so the
+  // Form.Items inside it have valid initial values (KCP needs MTU=1350
+  // etc., not empty strings).
+  const newStreamSlice = (n: string): Record<string, unknown> => {
+    switch (n) {
+      case 'tcp':
+        return { header: { type: 'none' } };
+      case 'kcp':
+        return {
+          mtu: 1350, tti: 20, uplinkCapacity: 5, downlinkCapacity: 20,
+          congestion: false, readBufferSize: 2, writeBufferSize: 2,
+          header: { type: 'none' }, seed: '',
+        };
+      case 'ws':
+        return { path: '/', host: '', headers: {}, heartbeatPeriod: 0 };
+      case 'grpc':
+        return { serviceName: '', authority: '', multiMode: false };
+      case 'httpupgrade':
+        return { path: '/', host: '', headers: {} };
+      case 'xhttp':
+        return {
+          path: '/', host: '', mode: 'auto', headers: {},
+          xPaddingBytes: '100-1000', scMaxEachPostBytes: '1000000',
+        };
+      default:
+        return {};
+    }
+  };
   const onNetworkChange = (next: string) => {
     const ALL = ['tcpSettings', 'kcpSettings', 'wsSettings', 'grpcSettings', 'httpupgradeSettings', 'xhttpSettings'];
     const current = (form.getFieldValue('streamSettings') as Record<string, unknown>) ?? {};
@@ -1307,7 +1337,7 @@ export default function InboundFormModal({
     for (const k of ALL) {
       if (k !== `${next}Settings`) delete cleaned[k];
     }
-    cleaned[`${next}Settings`] = {};
+    cleaned[`${next}Settings`] = newStreamSlice(next);
     form.setFieldValue('streamSettings', cleaned);
   };