Browse Source

refactor(ui): share one onNumber handler for numeric setting inputs (#6127)

* refactor(ui): share one onNumber handler for numeric setting inputs

The Number(v) || 0 idiom in InputNumber onChange handlers is the root
pattern behind the cleared-port bug (#6121): AntD reports a cleared
field as null, and || 0 turns that into a stored zero or a min-clamp.
The port fields got an inline null-guard; the other sixteen numeric
settings kept the idiom, so every new field is a chance to
reintroduce the bug.

Extract the guard into onNumber(apply): null, empty and NaN change
events are ignored so a cleared field snaps back to its stored value
on blur, and numeric events pass through unchanged. Convert all
sixteen sites in the settings and xray pages. Two sites keep their
deliberate different semantics: smtpPort falls back to 587 on clear,
and the Telegram notify interval clamps through Math.max.

For the non-port fields this changes clearing from storing 0 to
keeping the stored value; zero remains reachable by typing it.

Co-Authored-By: Claude Fable 5 <[email protected]>

* refactor(ui): fold the remaining hand-rolled numeric guards into onNumber

From review: ObservatorySettingsTab's sampling field hand-rolled the
same ignore-null semantic and smtpPort kept a fallback-to-587 on
clear that nothing documents as intentional and that silently
overwrites a configured non-standard port — both now go through the
shared helper, leaving the Telegram interval clamp as the one
deliberate exception.

Also from review: narrow the helper to numbers only (no stringMode
input exists in the repo, and the string branch codified a guarantee
the number-typed callback cannot honour), soften the docblock to
describe behavior rather than promise prevention, add a GeneralTab
component test covering the clear-vs-typed-zero semantics, and assert
the blur snap-back in both settings tests so a display/state desync
cannot ship unnoticed.

Co-Authored-By: Claude Fable 5 <[email protected]>

---------

Co-authored-by: Claude Fable 5 <[email protected]>
PathGao 5 hours ago
parent
commit
411271b454

+ 2 - 1
frontend/src/pages/settings/EmailTab.tsx

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
 import { Alert, Button, Input, InputNumber, Select, Space, Switch, Tabs } from 'antd';
 import { Alert, Button, Input, InputNumber, Select, Space, Switch, Tabs } from 'antd';
 import { MailOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons';
 import { MailOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons';
 import { HttpUtil } from '@/utils';
 import { HttpUtil } from '@/utils';
+import { onNumber } from '@/utils/onNumber';
 import type { AllSetting } from '@/models/setting';
 import type { AllSetting } from '@/models/setting';
 import { SettingListItem } from '@/components/ui';
 import { SettingListItem } from '@/components/ui';
 import { EmailNotifications } from '@/components/ui/notifications/EmailNotifications';
 import { EmailNotifications } from '@/components/ui/notifications/EmailNotifications';
@@ -64,7 +65,7 @@ export default function EmailTab({ allSetting, updateSetting }: EmailTabProps) {
 
 
             <SettingListItem paddings="small" title={t('pages.settings.smtpPort')} description={t('pages.settings.smtpPortDesc')}>
             <SettingListItem paddings="small" title={t('pages.settings.smtpPort')} description={t('pages.settings.smtpPortDesc')}>
               <InputNumber value={allSetting.smtpPort} min={1} max={65535} style={{ width: '100%' }}
               <InputNumber value={allSetting.smtpPort} min={1} max={65535} style={{ width: '100%' }}
-                onChange={(v) => updateSetting({ smtpPort: Number(v) || 587 })} />
+                onChange={onNumber((v) => updateSetting({ smtpPort: v }))} />
             </SettingListItem>
             </SettingListItem>
 
 
             <SettingListItem paddings="small" title={t('pages.settings.smtpUsername')} description={t('pages.settings.smtpUsernameDesc')}>
             <SettingListItem paddings="small" title={t('pages.settings.smtpUsername')} description={t('pages.settings.smtpUsernameDesc')}>

+ 10 - 9
frontend/src/pages/settings/GeneralTab.tsx

@@ -17,6 +17,7 @@ import {
 } from '@ant-design/icons';
 } from '@ant-design/icons';
 import type { AllSetting } from '@/models/setting';
 import type { AllSetting } from '@/models/setting';
 import { HttpUtil, LanguageManager } from '@/utils';
 import { HttpUtil, LanguageManager } from '@/utils';
+import { onNumber } from '@/utils/onNumber';
 import { SettingListItem } from '@/components/ui';
 import { SettingListItem } from '@/components/ui';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { catTabLabel } from './catTabLabel';
 import { catTabLabel } from './catTabLabel';
@@ -170,7 +171,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
 
 
             <SettingListItem paddings="small" title={t('pages.settings.panelPort')} description={t('pages.settings.panelPortDesc')}>
             <SettingListItem paddings="small" title={t('pages.settings.panelPort')} description={t('pages.settings.panelPortDesc')}>
               <InputNumber value={allSetting.webPort} min={1} max={65535} style={{ width: '100%' }}
               <InputNumber value={allSetting.webPort} min={1} max={65535} style={{ width: '100%' }}
-                onChange={(v) => { if (v != null) updateSetting({ webPort: v }); }} />
+                onChange={onNumber((v) => updateSetting({ webPort: v }))} />
             </SettingListItem>
             </SettingListItem>
 
 
             <SettingListItem paddings="small" title={t('pages.settings.panelUrlPath')} description={t('pages.settings.panelUrlPathDesc')}>
             <SettingListItem paddings="small" title={t('pages.settings.panelUrlPath')} description={t('pages.settings.panelUrlPathDesc')}>
@@ -179,7 +180,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
 
 
             <SettingListItem paddings="small" title={t('pages.settings.sessionMaxAge')} description={t('pages.settings.sessionMaxAgeDesc')}>
             <SettingListItem paddings="small" title={t('pages.settings.sessionMaxAge')} description={t('pages.settings.sessionMaxAgeDesc')}>
               <InputNumber value={allSetting.sessionMaxAge} min={60} max={525600} style={{ width: '100%' }}
               <InputNumber value={allSetting.sessionMaxAge} min={60} max={525600} style={{ width: '100%' }}
-                onChange={(v) => updateSetting({ sessionMaxAge: Number(v) || 0 })} />
+                onChange={onNumber((v) => updateSetting({ sessionMaxAge: v }))} />
             </SettingListItem>
             </SettingListItem>
 
 
             <SettingListItem
             <SettingListItem
@@ -208,7 +209,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
 
 
             <SettingListItem paddings="small" title={t('pages.settings.pageSize')} description={t('pages.settings.pageSizeDesc')}>
             <SettingListItem paddings="small" title={t('pages.settings.pageSize')} description={t('pages.settings.pageSizeDesc')}>
               <InputNumber value={allSetting.pageSize} min={0} max={1000} step={5} style={{ width: '100%' }}
               <InputNumber value={allSetting.pageSize} min={0} max={1000} step={5} style={{ width: '100%' }}
-                onChange={(v) => updateSetting({ pageSize: Number(v) || 0 })} />
+                onChange={onNumber((v) => updateSetting({ pageSize: v }))} />
             </SettingListItem>
             </SettingListItem>
 
 
             <SettingListItem paddings="small" title={t('pages.settings.restartXrayOnClientDisable')} description={t('pages.settings.restartXrayOnClientDisableDesc')}>
             <SettingListItem paddings="small" title={t('pages.settings.restartXrayOnClientDisable')} description={t('pages.settings.restartXrayOnClientDisableDesc')}>
@@ -234,11 +235,11 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
           <>
           <>
             <SettingListItem paddings="small" title={t('pages.settings.expireTimeDiff')} description={t('pages.settings.expireTimeDiffDesc')}>
             <SettingListItem paddings="small" title={t('pages.settings.expireTimeDiff')} description={t('pages.settings.expireTimeDiffDesc')}>
               <InputNumber value={allSetting.expireDiff} min={0} style={{ width: '100%' }}
               <InputNumber value={allSetting.expireDiff} min={0} style={{ width: '100%' }}
-                onChange={(v) => updateSetting({ expireDiff: Number(v) || 0 })} />
+                onChange={onNumber((v) => updateSetting({ expireDiff: v }))} />
             </SettingListItem>
             </SettingListItem>
             <SettingListItem paddings="small" title={t('pages.settings.trafficDiff')} description={t('pages.settings.trafficDiffDesc')}>
             <SettingListItem paddings="small" title={t('pages.settings.trafficDiff')} description={t('pages.settings.trafficDiffDesc')}>
               <InputNumber value={allSetting.trafficDiff} min={0} max={100} style={{ width: '100%' }}
               <InputNumber value={allSetting.trafficDiff} min={0} max={100} style={{ width: '100%' }}
-                onChange={(v) => updateSetting({ trafficDiff: Number(v) || 0 })} />
+                onChange={onNumber((v) => updateSetting({ trafficDiff: v }))} />
             </SettingListItem>
             </SettingListItem>
           </>
           </>
         ),
         ),
@@ -308,7 +309,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
             </SettingListItem>
             </SettingListItem>
             <SettingListItem paddings="small" title={t('pages.settings.ldap.port')}>
             <SettingListItem paddings="small" title={t('pages.settings.ldap.port')}>
               <InputNumber value={allSetting.ldapPort} min={1} max={65535} style={{ width: '100%' }}
               <InputNumber value={allSetting.ldapPort} min={1} max={65535} style={{ width: '100%' }}
-                onChange={(v) => { if (v != null) updateSetting({ ldapPort: v }); }} />
+                onChange={onNumber((v) => updateSetting({ ldapPort: v }))} />
             </SettingListItem>
             </SettingListItem>
             <SettingListItem paddings="small" title={t('pages.settings.ldap.useTls')}>
             <SettingListItem paddings="small" title={t('pages.settings.ldap.useTls')}>
               <Switch checked={allSetting.ldapUseTLS} onChange={(v) => updateSetting({ ldapUseTLS: v })} />
               <Switch checked={allSetting.ldapUseTLS} onChange={(v) => updateSetting({ ldapUseTLS: v })} />
@@ -387,15 +388,15 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
             </SettingListItem>
             </SettingListItem>
             <SettingListItem paddings="small" title={t('pages.settings.ldap.defaultTotalGb')}>
             <SettingListItem paddings="small" title={t('pages.settings.ldap.defaultTotalGb')}>
               <InputNumber value={allSetting.ldapDefaultTotalGB} min={0} style={{ width: '100%' }}
               <InputNumber value={allSetting.ldapDefaultTotalGB} min={0} style={{ width: '100%' }}
-                onChange={(v) => updateSetting({ ldapDefaultTotalGB: Number(v) || 0 })} />
+                onChange={onNumber((v) => updateSetting({ ldapDefaultTotalGB: v }))} />
             </SettingListItem>
             </SettingListItem>
             <SettingListItem paddings="small" title={t('pages.settings.ldap.defaultExpiryDays')}>
             <SettingListItem paddings="small" title={t('pages.settings.ldap.defaultExpiryDays')}>
               <InputNumber value={allSetting.ldapDefaultExpiryDays} min={0} style={{ width: '100%' }}
               <InputNumber value={allSetting.ldapDefaultExpiryDays} min={0} style={{ width: '100%' }}
-                onChange={(v) => updateSetting({ ldapDefaultExpiryDays: Number(v) || 0 })} />
+                onChange={onNumber((v) => updateSetting({ ldapDefaultExpiryDays: v }))} />
             </SettingListItem>
             </SettingListItem>
             <SettingListItem paddings="small" title={t('pages.settings.ldap.defaultIpLimit')}>
             <SettingListItem paddings="small" title={t('pages.settings.ldap.defaultIpLimit')}>
               <InputNumber value={allSetting.ldapDefaultLimitIP} min={0} style={{ width: '100%' }}
               <InputNumber value={allSetting.ldapDefaultLimitIP} min={0} style={{ width: '100%' }}
-                onChange={(v) => updateSetting({ ldapDefaultLimitIP: Number(v) || 0 })} />
+                onChange={onNumber((v) => updateSetting({ ldapDefaultLimitIP: v }))} />
             </SettingListItem>
             </SettingListItem>
           </>
           </>
         ),
         ),

+ 3 - 2
frontend/src/pages/settings/SubscriptionFormatsTab.tsx

@@ -17,6 +17,7 @@ import {
   SettingOutlined,
   SettingOutlined,
 } from '@ant-design/icons';
 } from '@ant-design/icons';
 import type { AllSetting } from '@/models/setting';
 import type { AllSetting } from '@/models/setting';
+import { onNumber } from '@/utils/onNumber';
 import { SettingListItem } from '@/components/ui';
 import { SettingListItem } from '@/components/ui';
 import { GoRegexInput } from '@/components/form';
 import { GoRegexInput } from '@/components/form';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
@@ -279,11 +280,11 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
               <div className="format-settings">
               <div className="format-settings">
                 <SettingListItem paddings="small" title={t('pages.settings.subFormats.concurrency')}>
                 <SettingListItem paddings="small" title={t('pages.settings.subFormats.concurrency')}>
                   <InputNumber value={muxObj.concurrency} min={-1} max={1024} style={{ width: '100%' }}
                   <InputNumber value={muxObj.concurrency} min={-1} max={1024} style={{ width: '100%' }}
-                    onChange={(v) => setMuxField('concurrency', Number(v) || 0)} />
+                    onChange={onNumber((v) => setMuxField('concurrency', v))} />
                 </SettingListItem>
                 </SettingListItem>
                 <SettingListItem paddings="small" title={t('pages.settings.subFormats.xudpConcurrency')}>
                 <SettingListItem paddings="small" title={t('pages.settings.subFormats.xudpConcurrency')}>
                   <InputNumber value={muxObj.xudpConcurrency} min={-1} max={1024} style={{ width: '100%' }}
                   <InputNumber value={muxObj.xudpConcurrency} min={-1} max={1024} style={{ width: '100%' }}
-                    onChange={(v) => setMuxField('xudpConcurrency', Number(v) || 0)} />
+                    onChange={onNumber((v) => setMuxField('xudpConcurrency', v))} />
                 </SettingListItem>
                 </SettingListItem>
                 <SettingListItem paddings="small" title={t('pages.settings.subFormats.xudpUdp443')}>
                 <SettingListItem paddings="small" title={t('pages.settings.subFormats.xudpUdp443')}>
                   <Select
                   <Select

+ 3 - 2
frontend/src/pages/settings/SubscriptionGeneralTab.tsx

@@ -3,6 +3,7 @@ import { BranchesOutlined, CompassOutlined, IdcardOutlined, InfoCircleOutlined,
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { useNavigate } from 'react-router';
 import { useNavigate } from 'react-router';
 import type { AllSetting } from '@/models/setting';
 import type { AllSetting } from '@/models/setting';
+import { onNumber } from '@/utils/onNumber';
 import { SettingListItem } from '@/components/ui';
 import { SettingListItem } from '@/components/ui';
 import { RemarkTemplateField } from '@/components/form';
 import { RemarkTemplateField } from '@/components/form';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
@@ -57,7 +58,7 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
             </SettingListItem>
             </SettingListItem>
             <SettingListItem paddings="small" title={t('pages.settings.subPort')} description={t('pages.settings.subPortDesc')}>
             <SettingListItem paddings="small" title={t('pages.settings.subPort')} description={t('pages.settings.subPortDesc')}>
               <InputNumber value={allSetting.subPort} min={1} max={65535} style={{ width: '100%' }}
               <InputNumber value={allSetting.subPort} min={1} max={65535} style={{ width: '100%' }}
-                onChange={(v) => { if (v != null) updateSetting({ subPort: v }); }} />
+                onChange={onNumber((v) => updateSetting({ subPort: v }))} />
             </SettingListItem>
             </SettingListItem>
             <SettingListItem paddings="small" title={t('pages.settings.subPath')} description={t('pages.settings.subPathDesc')}>
             <SettingListItem paddings="small" title={t('pages.settings.subPath')} description={t('pages.settings.subPathDesc')}>
               <Input
               <Input
@@ -106,7 +107,7 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
 
 
             <SettingListItem paddings="small" title={t('pages.settings.subUpdates')} description={t('pages.settings.subUpdatesDesc')}>
             <SettingListItem paddings="small" title={t('pages.settings.subUpdates')} description={t('pages.settings.subUpdatesDesc')}>
               <InputNumber value={allSetting.subUpdates} min={0} max={525600} style={{ width: '100%' }}
               <InputNumber value={allSetting.subUpdates} min={0} max={525600} style={{ width: '100%' }}
-                onChange={(v) => updateSetting({ subUpdates: Number(v) || 0 })} />
+                onChange={onNumber((v) => updateSetting({ subUpdates: v }))} />
             </SettingListItem>
             </SettingListItem>
           </>
           </>
         ),
         ),

+ 2 - 1
frontend/src/pages/xray/balancers/ObservatorySettingsTab.tsx

@@ -2,6 +2,7 @@ import { useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { Alert, Empty, Input, InputNumber, Select, Space, Switch, Tag } from 'antd';
 import { Alert, Empty, Input, InputNumber, Select, Space, Switch, Tag } from 'antd';
 
 
+import { onNumber } from '@/utils/onNumber';
 import { SettingListItem } from '@/components/ui';
 import { SettingListItem } from '@/components/ui';
 import {
 import {
   BurstObservatorySchema,
   BurstObservatorySchema,
@@ -195,7 +196,7 @@ export default function ObservatorySettingsTab({
         <InputNumber
         <InputNumber
           min={1}
           min={1}
           value={burst.pingConfig.sampling}
           value={burst.pingConfig.sampling}
-          onChange={(v) => patchPingConfig({ sampling: typeof v === 'number' ? v : burst.pingConfig.sampling })}
+          onChange={onNumber((v) => patchPingConfig({ sampling: v }))}
           style={{ width: '100%' }}
           style={{ width: '100%' }}
         />
         />
       </SettingListItem>
       </SettingListItem>

+ 2 - 1
frontend/src/pages/xray/dns/DnsTab.tsx

@@ -11,6 +11,7 @@ import {
   SettingOutlined,
   SettingOutlined,
 } from '@ant-design/icons';
 } from '@ant-design/icons';
 
 
+import { onNumber } from '@/utils/onNumber';
 import { SettingListItem } from '@/components/ui';
 import { SettingListItem } from '@/components/ui';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { catTabLabel } from '@/pages/settings/catTabLabel';
 import { catTabLabel } from '@/pages/settings/catTabLabel';
@@ -311,7 +312,7 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
                       min={0}
                       min={0}
                       step={60}
                       step={60}
                       style={{ width: '100%' }}
                       style={{ width: '100%' }}
-                      onChange={(v) => setDnsField('serveExpiredTTL', Number(v) || 0)}
+                      onChange={onNumber((v) => setDnsField('serveExpiredTTL', v))}
                     />
                     />
                   }
                   }
                 />
                 />

+ 3 - 1
frontend/src/pages/xray/dns/useDnsColumns.tsx

@@ -4,6 +4,8 @@ import { Button, Dropdown, Input, InputNumber, Space } from 'antd';
 import { MoreOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
 import { MoreOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
 import type { ColumnsType } from 'antd/es/table';
 import type { ColumnsType } from 'antd/es/table';
 
 
+import { onNumber } from '@/utils/onNumber';
+
 import { addrFor, domainsFor, expectedIPsFor } from './helpers';
 import { addrFor, domainsFor, expectedIPsFor } from './helpers';
 import type { DnsServerValue } from './DnsServerModal';
 import type { DnsServerValue } from './DnsServerModal';
 
 
@@ -113,7 +115,7 @@ export function useFakednsColumns({
             aria-label={t('pages.xray.fakedns.poolSize')}
             aria-label={t('pages.xray.fakedns.poolSize')}
             min={1}
             min={1}
             size="small"
             size="small"
-            onChange={(v) => updateFakednsField(index, 'poolSize', Number(v) || 0)}
+            onChange={onNumber((v) => updateFakednsField(index, 'poolSize', v))}
           />
           />
         ),
         ),
       },
       },

+ 3 - 2
frontend/src/pages/xray/outbounds/OutboundsTab.tsx

@@ -38,6 +38,7 @@ import {
 } from '@ant-design/icons';
 } from '@ant-design/icons';
 
 
 import { HttpUtil } from '@/utils';
 import { HttpUtil } from '@/utils';
+import { onNumber } from '@/utils/onNumber';
 import PromptModal from '@/components/feedback/PromptModal';
 import PromptModal from '@/components/feedback/PromptModal';
 import TextModal from '@/components/feedback/TextModal';
 import TextModal from '@/components/feedback/TextModal';
 
 
@@ -626,14 +627,14 @@ export default function OutboundsTab({
                   <InputNumber
                   <InputNumber
                     min={0}
                     min={0}
                     value={intervalHours}
                     value={intervalHours}
-                    onChange={(v) => setIntervalHM(Number(v) || 0, intervalMinutes)}
+                    onChange={onNumber((v) => setIntervalHM(v, intervalMinutes))}
                     style={{ width: 80 }}
                     style={{ width: 80 }}
                   /> {t('pages.xray.outboundSub.hours')}
                   /> {t('pages.xray.outboundSub.hours')}
                   <InputNumber
                   <InputNumber
                     min={0}
                     min={0}
                     max={59}
                     max={59}
                     value={intervalMinutes}
                     value={intervalMinutes}
-                    onChange={(v) => setIntervalHM(intervalHours, Number(v) || 0)}
+                    onChange={onNumber((v) => setIntervalHM(intervalHours, v))}
                     style={{ width: 80 }}
                     style={{ width: 80 }}
                   /> {t('pages.xray.outboundSub.minutes')}
                   /> {t('pages.xray.outboundSub.minutes')}
                 </Space>
                 </Space>

+ 40 - 0
frontend/src/test/general-tab.test.tsx

@@ -0,0 +1,40 @@
+import { fireEvent, screen } from '@testing-library/react';
+import { MemoryRouter } from 'react-router';
+import { describe, expect, it, vi } from 'vitest';
+
+import { AllSetting } from '@/models/setting';
+import GeneralTab from '@/pages/settings/GeneralTab';
+import { renderWithProviders } from './test-utils';
+
+describe('GeneralTab', () => {
+  it('keeps the stored page size when the field is cleared', () => {
+    const updateSetting = vi.fn();
+
+    renderWithProviders(
+      <MemoryRouter initialEntries={['/settings']}>
+        <GeneralTab allSetting={new AllSetting({ pageSize: 25 })} updateSetting={updateSetting} />
+      </MemoryRouter>,
+    );
+
+    const pageSizeInput = screen.getByDisplayValue('25');
+    fireEvent.change(pageSizeInput, { target: { value: '' } });
+    fireEvent.blur(pageSizeInput);
+
+    expect(updateSetting).not.toHaveBeenCalled();
+    expect((pageSizeInput as HTMLInputElement).value).toBe('25');
+  });
+
+  it('forwards typed page sizes unchanged, zero included', () => {
+    const updateSetting = vi.fn();
+
+    renderWithProviders(
+      <MemoryRouter initialEntries={['/settings']}>
+        <GeneralTab allSetting={new AllSetting({ pageSize: 25 })} updateSetting={updateSetting} />
+      </MemoryRouter>,
+    );
+
+    fireEvent.change(screen.getByDisplayValue('25'), { target: { value: '0' } });
+
+    expect(updateSetting).toHaveBeenCalledWith({ pageSize: 0 });
+  });
+});

+ 23 - 0
frontend/src/test/on-number.test.ts

@@ -0,0 +1,23 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import { onNumber } from '@/utils/onNumber';
+
+describe('onNumber', () => {
+  it('forwards numeric values, including zero and negatives', () => {
+    const apply = vi.fn();
+    const handler = onNumber(apply);
+    handler(8443);
+    handler(0);
+    handler(-1);
+    expect(apply.mock.calls).toEqual([[8443], [0], [-1]]);
+  });
+
+  it('ignores cleared events instead of writing a synthetic value', () => {
+    const apply = vi.fn();
+    const handler = onNumber(apply);
+    handler(null);
+    handler(undefined);
+    handler(NaN);
+    expect(apply).not.toHaveBeenCalled();
+  });
+});

+ 1 - 0
frontend/src/test/subscription-general-tab.test.tsx

@@ -26,6 +26,7 @@ describe('SubscriptionGeneralTab', () => {
     fireEvent.blur(portInput);
     fireEvent.blur(portInput);
 
 
     expect(updateSetting).not.toHaveBeenCalled();
     expect(updateSetting).not.toHaveBeenCalled();
+    expect((portInput as HTMLInputElement).value).toBe('2096');
   });
   });
 
 
   it('forwards typed subscription ports unchanged', () => {
   it('forwards typed subscription ports unchanged', () => {

+ 16 - 0
frontend/src/utils/onNumber.ts

@@ -0,0 +1,16 @@
+/**
+ * Wraps an Ant Design InputNumber change handler with the shared
+ * cleared-field semantic: null and undefined change events (a cleared or
+ * unparsable field) are ignored, leaving the stored value in place — the
+ * input snaps back on blur — while real numbers pass through unchanged.
+ * Number-only by design: not for `stringMode` inputs, whose whole point is
+ * to avoid the IEEE-754 round trip this signature would force.
+ */
+export function onNumber(
+  apply: (value: number) => void,
+): (value: number | null | undefined) => void {
+  return (value) => {
+    if (typeof value !== 'number' || !Number.isFinite(value)) return;
+    apply(value);
+  };
+}