From 8e48e4ed10f825c978916da0e49244ea94164c4b Mon Sep 17 00:00:00 2001 From: Sukka Date: Mon, 22 Dec 2025 12:51:20 +0800 Subject: [PATCH] chore(eslint): add `eslint-plugin-react-compiler` (#5918) * chore(eslint): add `eslint-plugin-react-compiler` * refactor: fix eslint warnings and avoid in-render mutations --------- Co-authored-by: Slinetrac --- eslint.config.ts | 3 ++ package.json | 1 + pnpm-lock.yaml | 51 +++++++++++++++++++++ src/components/base/base-search-box.tsx | 4 +- src/components/profile/editor-viewer.tsx | 35 +++++++------- src/components/profile/profile-viewer.tsx | 22 ++++----- src/components/setting/mods/web-ui-item.tsx | 26 ++++------- src/hooks/use-proxy-selection.ts | 13 ++---- 8 files changed, 100 insertions(+), 55 deletions(-) diff --git a/eslint.config.ts b/eslint.config.ts index f8f51a631..e1db12c55 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -5,6 +5,7 @@ import configPrettier from "eslint-config-prettier"; import { createTypeScriptImportResolver } from "eslint-import-resolver-typescript"; import pluginImportX from "eslint-plugin-import-x"; import pluginPrettier from "eslint-plugin-prettier"; +import pluginReactCompiler from "eslint-plugin-react-compiler"; import pluginReactHooks from "eslint-plugin-react-hooks"; import pluginReactRefresh from "eslint-plugin-react-refresh"; import pluginUnusedImports from "eslint-plugin-unused-imports"; @@ -19,6 +20,7 @@ export default defineConfig([ js: eslintJS, // @ts-expect-error -- https://github.com/typescript-eslint/typescript-eslint/issues/11543 "react-hooks": pluginReactHooks, + "react-compiler": pluginReactCompiler, // @ts-expect-error -- https://github.com/un-ts/eslint-plugin-import-x/issues/421 "import-x": pluginImportX, "react-refresh": pluginReactRefresh, @@ -52,6 +54,7 @@ export default defineConfig([ // React "react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": "error", + "react-compiler/react-compiler": "error", "react-refresh/only-export-components": [ "warn", { allowConstantExport: true }, diff --git a/package.json b/package.json index 4f0936ca8..dea5545f3 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ "eslint-plugin-import-x": "^4.16.1", "eslint-plugin-prettier": "^5.5.4", "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-compiler": "^19.1.0-rc.2", "eslint-plugin-react-refresh": "^0.4.26", "eslint-plugin-unused-imports": "^4.3.0", "glob": "^13.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 523bf7e99..5b410fc9e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -192,6 +192,9 @@ importers: eslint-plugin-prettier: specifier: ^5.5.4 version: 5.5.4(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.7.4) + eslint-plugin-react-compiler: + specifier: ^19.1.0-rc.2 + version: 19.1.0-rc.2(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react-hooks: specifier: ^7.0.1 version: 7.0.1(eslint@9.39.2(jiti@2.6.1)) @@ -393,6 +396,13 @@ packages: peerDependencies: '@babel/core': ^7.0.0 + '@babel/plugin-proposal-private-methods@7.18.6': + resolution: {integrity: sha512-nutsvktDItsNn4rpGItSNV2sz1XwS+nfU0Rg8aCx3W3NOKVzdMjJRu0O5OkgDp3ZGICSTbgRpxZoWsxoKRvbeA==} + engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-private-methods instead. + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2': resolution: {integrity: sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==} engines: {node: '>=6.9.0'} @@ -2549,6 +2559,12 @@ packages: eslint-config-prettier: optional: true + eslint-plugin-react-compiler@19.1.0-rc.2: + resolution: {integrity: sha512-oKalwDGcD+RX9mf3NEO4zOoUMeLvjSvcbbEOpquzmzqEEM2MQdp7/FY/Hx9NzmUwFzH1W9SKTz5fihfMldpEYw==} + engines: {node: ^14.17.0 || ^16.0.0 || >= 18.0.0} + peerDependencies: + eslint: '>=7' + eslint-plugin-react-dom@2.3.13: resolution: {integrity: sha512-O9jglTOnnuyfJcSxjeVc8lqIp5kuS9/0MLLCHlOTH8ZjIifHHxUr6GZ2fd4la9y0FsoEYXEO7DBIMjWx2vCwjg==} engines: {node: '>=20.19.0'} @@ -4109,12 +4125,21 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod-validation-error@3.5.4: + resolution: {integrity: sha512-+hEiRIiPobgyuFlEojnqjJnhFvg4r/i3cqgcm67eehZf/WBaK3g6cD02YU9mtdVxZjv8CzCA9n/Rhrs3yAAvAw==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.24.4 + zod-validation-error@4.0.2: resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} engines: {node: '>=18.0.0'} peerDependencies: zod: ^3.25.0 || ^4.0.0 + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.2.1: resolution: {integrity: sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==} @@ -4331,6 +4356,14 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-proposal-private-methods@7.18.6(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-create-class-features-plugin': 7.28.5(@babel/core@7.28.5) + '@babel/helper-plugin-utils': 7.27.1 + transitivePeerDependencies: + - supports-color + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -6626,6 +6659,18 @@ snapshots: optionalDependencies: eslint-config-prettier: 10.1.8(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-react-compiler@19.1.0-rc.2(eslint@9.39.2(jiti@2.6.1)): + dependencies: + '@babel/core': 7.28.5 + '@babel/parser': 7.28.5 + '@babel/plugin-proposal-private-methods': 7.18.6(@babel/core@7.28.5) + eslint: 9.39.2(jiti@2.6.1) + hermes-parser: 0.25.1 + zod: 3.25.76 + zod-validation-error: 3.5.4(zod@3.25.76) + transitivePeerDependencies: + - supports-color + eslint-plugin-react-dom@2.3.13(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: '@eslint-react/ast': 2.3.13(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) @@ -8583,10 +8628,16 @@ snapshots: yocto-queue@0.1.0: {} + zod-validation-error@3.5.4(zod@3.25.76): + dependencies: + zod: 3.25.76 + zod-validation-error@4.0.2(zod@4.2.1): dependencies: zod: 4.2.1 + zod@3.25.76: {} + zod@4.2.1: {} zwitch@2.0.4: {} diff --git a/src/components/base/base-search-box.tsx b/src/components/base/base-search-box.tsx index 72ccfb4e6..97367454d 100644 --- a/src/components/base/base-search-box.tsx +++ b/src/components/base/base-search-box.tsx @@ -12,7 +12,7 @@ import { useTranslation } from "react-i18next"; import matchCaseIcon from "@/assets/image/component/match_case.svg?react"; import matchWholeWordIcon from "@/assets/image/component/match_whole_word.svg?react"; -import useRegularExpressionIcon from "@/assets/image/component/use_regular_expression.svg?react"; +import UseRegularExpressionIcon from "@/assets/image/component/use_regular_expression.svg?react"; import { buildRegex, compileStringMatcher } from "@/utils/search-matcher"; export type SearchState = { @@ -223,7 +223,7 @@ export const BaseSearchBox = ({
(props: Props) => { useEffect(() => { initialDataRef.current = initialData; }, [initialData]); - // Background refresh: when the dialog/model is ready and the underlying resource key changes, - // try to refresh content (only if user hasn't typed). Do NOT depend on `initialData` function - // identity because callers often pass inline lambdas that change every render. - useEffect(() => { - if (!open) return; - // Only attempt after initial model is ready to avoid racing the initial load - if (!modelPath) return; - // Avoid immediate double-load on open: the initial load has just completed. - if (skipNextRefreshRef.current) { - skipNextRefreshRef.current = false; - return; - } - // Only meaningful when a callable loader is provided (plain Promise cannot be "recalled") - if (typeof initialDataRef.current === "function") { - void reloadLatest(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, modelPath, dataKey]); // Helper to (soft) reload latest source and apply only if the user hasn't typed yet const reloadLatest = useLockFn(async () => { // Snapshot the model/doc identity and bump a token so older calls can't win @@ -201,6 +183,23 @@ export const EditorViewer = (props: Props) => { } } }); + // Background refresh: when the dialog/model is ready and the underlying resource key changes, + // try to refresh content (only if user hasn't typed). Do NOT depend on `initialData` function + // identity because callers often pass inline lambdas that change every render. + useEffect(() => { + if (!open) return; + // Only attempt after initial model is ready to avoid racing the initial load + if (!modelPath) return; + // Avoid immediate double-load on open: the initial load has just completed. + if (skipNextRefreshRef.current) { + skipNextRefreshRef.current = false; + return; + } + // Only meaningful when a callable loader is provided (plain Promise cannot be "recalled") + if (typeof initialDataRef.current === "function") { + void reloadLatest(); + } + }, [open, modelPath, dataKey, reloadLatest]); const beforeMount = () => { monacoInitialization(); diff --git a/src/components/profile/profile-viewer.tsx b/src/components/profile/profile-viewer.tsx index d441f2678..6324bf330 100644 --- a/src/components/profile/profile-viewer.tsx +++ b/src/components/profile/profile-viewer.tsx @@ -88,10 +88,6 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { const handleOk = useLockFn( handleSubmit(async (form) => { - if (form.option?.timeout_seconds) { - form.option.timeout_seconds = +form.option.timeout_seconds; - } - setLoading(true); try { // 基本验证 @@ -101,17 +97,21 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { } // 处理表单数据 - if (form.option?.update_interval) { - form.option.update_interval = +form.option.update_interval; - } else { - delete form.option?.update_interval; + const option = form.option ? { ...form.option } : undefined; + if (option?.timeout_seconds) { + option.timeout_seconds = +option.timeout_seconds; } - if (form.option?.user_agent === "") { - delete form.option.user_agent; + if (option?.update_interval) { + option.update_interval = +option.update_interval; + } else if (option) { + option.update_interval = undefined; + } + if (option?.user_agent === "") { + option.user_agent = undefined; } const name = form.name || `${form.type} file`; - const item = { ...form, name }; + const item = { ...form, name, option }; const isRemote = form.type === "remote"; const isUpdate = openType === "edit"; diff --git a/src/components/setting/mods/web-ui-item.tsx b/src/components/setting/mods/web-ui-item.tsx index 59869ce20..3cf12f6e8 100644 --- a/src/components/setting/mods/web-ui-item.tsx +++ b/src/components/setting/mods/web-ui-item.tsx @@ -88,25 +88,19 @@ export const WebUIItem = (props: Props) => { ); } - const placeholderCounts: Record = {}; - let textCounter = 0; - const renderedParts = highlightedParts.map((part) => { + const renderedParts = highlightedParts.map((part, index) => { const isPlaceholder = part === "%host" || part === "%port" || part === "%secret"; + const repeatIndex = highlightedParts + .slice(0, index) + .filter((prev) => prev === part).length; + const key = `${part || "empty"}-${repeatIndex}`; - if (isPlaceholder) { - const count = placeholderCounts[part] ?? 0; - placeholderCounts[part] = count + 1; - return ( - - {part} - - ); - } - - const key = `text-${textCounter}-${part || "empty"}`; - textCounter += 1; - return {part}; + return ( + + {part} + + ); }); return ( diff --git a/src/hooks/use-proxy-selection.ts b/src/hooks/use-proxy-selection.ts index 4cfb6a960..f0761c6e4 100644 --- a/src/hooks/use-proxy-selection.ts +++ b/src/hooks/use-proxy-selection.ts @@ -62,18 +62,15 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => { try { if (current && !skipConfigSave) { - if (!current.selected) current.selected = []; - - const index = current.selected.findIndex( - (item) => item.name === groupName, - ); + const selected = current.selected ? [...current.selected] : []; + const index = selected.findIndex((item) => item.name === groupName); if (index < 0) { - current.selected.push({ name: groupName, now: proxyName }); + selected.push({ name: groupName, now: proxyName }); } else { - current.selected[index] = { name: groupName, now: proxyName }; + selected[index] = { name: groupName, now: proxyName }; } - await patchCurrent({ selected: current.selected }); + await patchCurrent({ selected }); } await selectNodeForGroup(groupName, proxyName);