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 (