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 (