From 9cca50265199bf815fed85aaa3ae281672568c33 Mon Sep 17 00:00:00 2001
From: Zhao Jianing <18910855655@163.com>
Date: Wed, 21 Oct 2026 13:14:55 +0800
Subject: [PATCH] fix: ScoreConversion leaks a valtio subscription per render
subscribeKey is called in the render body and its returned unsubscribe is
discarded: every re-render of every mounted ScoreConversion (used on
nearly all analyze chart cards) adds another permanent subscription with a
stale onePoint/onChange closure. One navbar toggle of the score system
then fans out to N accumulated callbacks (N = renders so far), which keep
firing after unmount.
Move the subscription into useEffect with cleanup. Red proof in the test:
rendering 4 times and flipping chartUserSettingState.onePointSys fires
onChange 4 times before the fix, exactly once after.
---
.../components/ScoreConversion.test.tsx | 47 +++++++++++++++++++
.../analyze/components/ScoreConversion.tsx | 24 ++++++----
2 files changed, 62 insertions(+), 9 deletions(-)
create mode 100644 apps/web/src/modules/analyze/components/ScoreConversion.test.tsx
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 (