Skip to content

Commit aa7add5

Browse files
committed
fix(app): toast 卸载清理读当前 timerRef 防计时器泄漏
批7 审查 P2-8:useTransientToast 卸载 cleanup 把 timerRef.current 快照在 effect 建立时(恒 null——toast 尚未显示),卸载清的是空快照=死守卫, 挂起计时器在卸载后仍触发 setState。改 cleanup 执行时直接读 ref 判空 clearTimeout(文件头 L2 纪律注释保留)。hook 测试以「卸载后零残留计时器」 为契约(vi.getTimerCount 可观测泄漏——旧实现卸载后仍挂 1)+ 到时自清/ 连续 toast 重置单计时器语义。
1 parent 7dfe373 commit aa7add5

2 files changed

Lines changed: 71 additions & 2 deletions

File tree

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* useTransientToast.test.tsx — 自绘 toast hook 计时器生命周期契约(批 7 审查 P2-8)。
4+
*
5+
* @ai-context: 覆盖卸载清理语义——原实现把 timerRef.current 快照在 effect
6+
* 建立时(恒 null——toast 尚未显示),卸载清的是空快照=死守卫,
7+
* 挂起的自动消失计时器在卸载后仍会触发 setState;修复后 cleanup
8+
* 直接读 ref,卸载即清。契约以「卸载后无残留计时器」为判定
9+
* (vi.getTimerCount 可观测泄漏;React 卸载后 setState 静默无感)。
10+
*/
11+
import { afterEach, describe, expect, it, vi } from "vitest";
12+
import { act, cleanup, renderHook } from "@testing-library/react";
13+
import { useTransientToast } from "./useTransientToast";
14+
15+
afterEach(() => {
16+
cleanup();
17+
vi.useRealTimers();
18+
});
19+
20+
describe("useTransientToast 计时器生命周期", () => {
21+
it("卸载清理挂起的自动消失计时器(P2-8 死守卫回归)", () => {
22+
vi.useFakeTimers();
23+
const { result, unmount } = renderHook(() => useTransientToast(3000));
24+
// Arrange/Act:显示一条 toast → 挂起一个自动消失计时器
25+
act(() => result.current.showToast("临时提示", "ok"));
26+
expect(result.current.toast).toBeTruthy();
27+
expect(vi.getTimerCount()).toBe(1);
28+
// Act:卸载(cleanup 须清掉挂起计时器——旧实现清的是 effect 建立时的
29+
// 空快照,挂起计时器泄漏=卸载后仍会触发 setState)
30+
act(() => unmount());
31+
// Assert:零残留(推进时间也不再有回调触发卸载后 setState)
32+
expect(vi.getTimerCount()).toBe(0);
33+
act(() => vi.advanceTimersByTime(4000));
34+
expect(vi.getTimerCount()).toBe(0);
35+
});
36+
37+
it("正常到时自动消失后卸载无残留", () => {
38+
vi.useFakeTimers();
39+
const { result, unmount } = renderHook(() => useTransientToast(1000));
40+
act(() => result.current.showToast("到时消失", "ok"));
41+
act(() => vi.advanceTimersByTime(1000));
42+
expect(result.current.toast).toBeNull();
43+
act(() => unmount());
44+
expect(vi.getTimerCount()).toBe(0);
45+
});
46+
47+
it("连续 toast 重置计时(单计时器语义——新消息覆盖旧消息)", () => {
48+
vi.useFakeTimers();
49+
const { result, unmount } = renderHook(() => useTransientToast(3000));
50+
act(() => result.current.showToast("第一条", "ok"));
51+
act(() => vi.advanceTimersByTime(1000));
52+
// Act:第二条重置计时(旧计时器清掉、只挂一个)
53+
act(() => result.current.showToast("第二条覆盖", "err"));
54+
expect(vi.getTimerCount()).toBe(1);
55+
// 注:fake timers 下断言 state(toast 内容在元素 props.children)
56+
const props = result.current.toast?.props as { children?: string } | undefined;
57+
expect(props?.children).toBe("第二条覆盖");
58+
// Assert:重置后的 3s 内仍在展示,越过则消失
59+
act(() => vi.advanceTimersByTime(2500));
60+
expect(result.current.toast).not.toBeNull();
61+
act(() => vi.advanceTimersByTime(501));
62+
expect(result.current.toast).toBeNull();
63+
act(() => unmount());
64+
expect(vi.getTimerCount()).toBe(0);
65+
});
66+
});

‎app/src/hooks/useTransientToast.tsx‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,13 @@ export function useTransientToast(durationMs = 3000): {
3838
[durationMs],
3939
);
4040

41-
// L2:卸载清理(防卸载后定时器触发 setState)
41+
// L2:卸载清理(防卸载后定时器触发 setState)——批 7 审查修复(P2-8):
42+
// 原实现把 timerRef.current 快照在 effect 建立时(恒为 null——toast 尚未
43+
// 显示),卸载时清的是空快照=死守卫;改在 cleanup 执行时直接读 ref 取
44+
// 当前挂起的计时器,判空后清除。
4245
useEffect(() => {
43-
const timer = timerRef.current;
4446
return () => {
47+
const timer = timerRef.current;
4548
if (timer) clearTimeout(timer);
4649
};
4750
}, []);

0 commit comments

Comments
 (0)