diff --git a/apps/web/src/modules/analyze/components/ScoreConversion.test.tsx b/apps/web/src/modules/analyze/components/ScoreConversion.test.tsx
new file mode 100644
index 0000000000..c5a199f75c
--- /dev/null
+++ b/apps/web/src/modules/analyze/components/ScoreConversion.test.tsx
@@ -0,0 +1,47 @@
+import { act, render } from '@testing-library/react';
+import ScoreConversion from './ScoreConversion';
+import { chartUserSettingState } from '@modules/analyze/store';
+
+jest.mock('next-i18next', () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+jest.mock('public/images/analyze/number-100.svg', () => ({
+ __esModule: true,
+ default: () => null,
+}));
+jest.mock('public/images/analyze/number-1.svg', () => ({
+ __esModule: true,
+ default: () => null,
+}));
+
+describe('ScoreConversion', () => {
+ beforeEach(() => {
+ chartUserSettingState.onePointSys = false;
+ });
+
+ it('does not accumulate subscriptions across re-renders', async () => {
+ const onChange = jest.fn();
+ const { rerender } = render(
+
+ );
+ rerender();
+ rerender();
+ rerender();
+
+ // valtio 的订阅回调经微任务通知(subscribeKey 未传 notifyInSync),
+ // 需 flush 后断言。
+ await act(async () => {
+ chartUserSettingState.onePointSys = true;
+ });
+ expect(onChange).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not call onChange when the store value equals the current one', async () => {
+ const onChange = jest.fn();
+ render();
+ await act(async () => {
+ chartUserSettingState.onePointSys = false;
+ });
+ expect(onChange).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/web/src/modules/analyze/components/ScoreConversion.tsx b/apps/web/src/modules/analyze/components/ScoreConversion.tsx
index 3cb5ceddb2..5707b1e928 100644
--- a/apps/web/src/modules/analyze/components/ScoreConversion.tsx
+++ b/apps/web/src/modules/analyze/components/ScoreConversion.tsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useEffect } from 'react';
import Svg100 from 'public/images/analyze/number-100.svg';
import Svg1 from 'public/images/analyze/number-1.svg';
import { useTranslation } from 'next-i18next';
@@ -11,15 +11,21 @@ const ScoreConversion: React.FC<{
onChange: (pre: boolean) => void;
}> = ({ onePoint, onChange }) => {
const { t } = useTranslation();
- const unsubscribe = subscribeKey(
- chartUserSettingState,
- 'onePointSys',
- (v) => {
- if (onePoint !== v) {
- onChange(v);
+ // subscribeKey 必须在 effect 中订阅并在卸载/依赖变化时退订:在渲染体内调用
+ // 会随每次重渲染累积一个永不退订的订阅,导航栏切换一次设置会触发全部累积
+ // 回调(闭包陈旧、卸载后仍 setState)。
+ useEffect(() => {
+ const unsubscribe = subscribeKey(
+ chartUserSettingState,
+ 'onePointSys',
+ (v) => {
+ if (onePoint !== v) {
+ onChange(v);
+ }
}
- }
- );
+ );
+ return unsubscribe;
+ }, [onePoint, onChange]);
return (