Răsfoiți Sursa

chore(lint): forbid the Number-or-clamp idiom in direct-write settings pages (#6129)

* chore(lint): forbid the Number-or-clamp idiom in direct-write settings pages

Follow-up promised in #6127's review thread: the settings and xray
pages write numeric changes straight into state, so a regressed
handler silently ships the cleared-port bug again. A scoped
no-restricted-syntax rule now rejects Number(...) || N inside an
onChange attribute in those directories, pointing at onNumber().

The one remaining match, the Telegram notify interval, moves onto the
helper with its floor intact: clearing now keeps the stored count
instead of writing 1, and Math.max still clamps typed values. Form
modals that stage values behind Zod keep their deliberate
clear-means-zero semantics; the rule deliberately does not apply
there.

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

* chore(lint): widen the numeric-clamp guard to the shapes that actually drift

From review: the rule matched only the Number-or-literal shape, while
two semantically identical ternary sites already lived inside its own
directories, so 'zero suppressions' reflected the selector's
narrowness rather than a clean subtree. The rule now catches the
ternary typeof form and the nullish-coalescing form too, is anchored
to InputNumber elements so its message can never point a ChangeEvent
handler at a number-typed helper, and documents the extracted-handler
shape it cannot see.

The xray form modals stage values behind Zod like the clients modals
do, so a follow-up config object exempts them explicitly instead of
the comment claiming they were never in scope.

BasicsTab's Happy Eyeballs try-delay — the one genuine direct-write
ternary — moves onto onNumber: clearing keeps the stored delay
instead of writing 0, and 0 stays reachable by typing it. The
Telegram interval gains precision={0} so a typed decimal cannot
compose an @every value its own parser rejects on reload.

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

---------

Co-authored-by: Claude Fable 5 <[email protected]>
PathGao 9 ore în urmă
părinte
comite
55f0281692

+ 33 - 0
frontend/eslint.config.js

@@ -53,4 +53,37 @@ export default [
       'jsx-a11y/no-autofocus': 'off',
     },
   },
+  {
+    // The settings and xray pages write numeric InputNumber changes straight
+    // into state, so a null-collapsing handler (`Number(v) || N`, or the
+    // ternary `typeof v === 'number' ? v : N`) turns a cleared field into a
+    // stored N — the cleared-port bug, #6121. Handlers here go through
+    // onNumber() (src/utils/onNumber.ts) instead. Known limit: a handler
+    // extracted into a variable and passed as onChange={handler} is not
+    // matched; the inline shapes below are the ones that drift in practice.
+    files: ['src/pages/settings/**/*.tsx', 'src/pages/xray/**/*.tsx'],
+    rules: {
+      'no-restricted-syntax': ['error', {
+        selector: 'JSXElement[openingElement.name.name="InputNumber"] JSXAttribute[name.name="onChange"] LogicalExpression[operator="||"] > CallExpression[callee.name="Number"]',
+        message: 'A cleared InputNumber must not write a synthetic value; wrap the handler with onNumber() from @/utils/onNumber (see #6127).',
+      }, {
+        selector: 'JSXElement[openingElement.name.name="InputNumber"] JSXAttribute[name.name="onChange"] ConditionalExpression[test.left.operator="typeof"][alternate.type="Literal"]',
+        message: 'A cleared InputNumber must not write a synthetic value; wrap the handler with onNumber() from @/utils/onNumber (see #6127).',
+      }, {
+        selector: 'JSXElement[openingElement.name.name="InputNumber"] JSXAttribute[name.name="onChange"] LogicalExpression[operator="??"][right.type="Literal"]',
+        message: 'A cleared InputNumber must not write a synthetic value; wrap the handler with onNumber() from @/utils/onNumber (see #6127).',
+      }],
+    },
+  },
+  {
+    // The xray form modals (OutboundFormModal, BalancerFormModal,
+    // DnsServerModal, WarpModal, …) stage values behind Zod validation like
+    // the clients/inbounds modals do, and some of their fields carry a
+    // deliberate clear-means-zero semantic — the direct-write rule above
+    // does not apply to them.
+    files: ['src/pages/xray/**/*Modal.tsx'],
+    rules: {
+      'no-restricted-syntax': 'off',
+    },
+  },
 ];

+ 3 - 1
frontend/src/pages/settings/TelegramTab.tsx

@@ -4,6 +4,7 @@ import { Alert, Button, Input, InputNumber, Select, Space, Switch, Tabs } from '
 import { BellOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons';
 import { LanguageManager } from '@/utils';
 import { HttpUtil } from '@/utils';
+import { onNumber } from '@/utils/onNumber';
 import type { AllSetting } from '@/models/setting';
 import { SettingListItem } from '@/components/ui';
 import { TelegramNotifications } from '@/components/ui/notifications/TelegramNotifications';
@@ -122,9 +123,10 @@ function NotifyTimeField({ value, onChange }: { value: string; onChange: (v: str
         <Space.Compact style={{ width: '100%' }}>
           <InputNumber
             min={1}
+            precision={0}
             style={{ width: '50%' }}
             value={state.num}
-            onChange={(v) => update({ num: Math.max(1, Number(v) || 1) })}
+            onChange={onNumber((v) => update({ num: Math.max(1, v) }))}
             aria-label={t('pages.settings.notifyTime.interval')}
           />
           <Select<Unit>

+ 4 - 3
frontend/src/pages/xray/basics/BasicsTab.tsx

@@ -1,4 +1,5 @@
 import { useCallback } from 'react';
+import { onNumber } from '@/utils/onNumber';
 import { useTranslation } from 'react-i18next';
 import { Alert, Button, Input, InputNumber, Modal, Select, Space, Switch, Tabs } from 'antd';
 import {
@@ -216,10 +217,10 @@ export default function BasicsTab({
                     style={{ width: '100%' }}
                     value={directHappyEyeballs.tryDelayMs}
                     placeholder="150"
-                    onChange={(v) => setDirectHappyEyeballs({
+                    onChange={onNumber((v) => setDirectHappyEyeballs({
                       ...directHappyEyeballs,
-                      tryDelayMs: typeof v === 'number' ? v : 0,
-                    })}
+                      tryDelayMs: v,
+                    }))}
                   />
                 }
               />