修复定时器与动画回调清理不完整问题

This commit is contained in:
2026-03-03 12:27:34 +08:00
parent 45dbac1935
commit 4cd593bc30
5 changed files with 57 additions and 12 deletions
+8 -3
View File
@@ -167,10 +167,15 @@
- 先清理 error 级规则,再统一处理 warning; - 先清理 error 级规则,再统一处理 warning;
- 针对 hooks 规则建立最小回归测试。 - 针对 hooks 规则建立最小回归测试。
### P2-5 定时器清理不完整(潜在内存泄漏/卸载后状态写入) ### P2-5 定时器清理不完整(潜在内存泄漏/卸载后状态写入)【已完成】
- 修复状态:✅ 已完成(2026-03-03
- 修复内容:
- `useBlindboxIdeas` 增加统一 `useEffect` cleanup,卸载时清理所有 `timersRef`
- `useBlindboxDraw` 增加 confetti 停止逻辑(timeout + `requestAnimationFrame` + `confetti.reset`),并在 `handleContinue` 与 unmount 时清理;
- 同步修正盲盒房间页与 `useBlindboxRoom` 的定时器 cleanup 方式,避免 ref 清理时机不稳定。
- 证据: - 证据:
- `src/hooks/useBlindboxIdeas.ts:52` + `:129`(保存定时器,但无统一 cleanup) - `src/hooks/useBlindboxIdeas.ts` 已新增 `timersRef` 统一清理;
- `src/hooks/useBlindboxDraw.ts:25` + `:38`(定时器与动画回调未见 unmount 清理) - `src/hooks/useBlindboxDraw.ts` 已新增 confetti 与动画回调销毁逻辑。
- 影响: - 影响:
- 页面切换或快速操作下可能出现卸载后 setState、额外渲染噪音。 - 页面切换或快速操作下可能出现卸载后 setState、额外渲染噪音。
- 建议: - 建议:
+5 -2
View File
@@ -42,7 +42,11 @@ export default function BlindboxRoomPage() {
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]); const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
useEffect(() => { useEffect(() => {
return () => { timersRef.current.forEach(clearTimeout); }; const timers = timersRef.current;
return () => {
timers.forEach(clearTimeout);
timers.length = 0;
};
}, []); }, []);
// Hook: Room // Hook: Room
@@ -58,7 +62,6 @@ export default function BlindboxRoomPage() {
input, setInput, submitting, suggestions, suggestionsLoading, input, setInput, submitting, suggestions, suggestionsLoading,
suggestionsSource, poolCount, setPoolCount, myIdeas, drawnHistory, suggestionsSource, poolCount, setPoolCount, myIdeas, drawnHistory,
setDrawnHistory, submitFlash, error, setError, inputRef, setDrawnHistory, submitFlash, error, setError, inputRef,
boxControls: ideaBoxControls,
fetchIdeas, fetchSuggestions, refreshSuggestions, fetchIdeas, fetchSuggestions, refreshSuggestions,
handleSubmit, handleEditIdea, handleDeleteIdea, handleSubmit, handleEditIdea, handleDeleteIdea,
} = useBlindboxIdeas(room, profile); } = useBlindboxIdeas(room, profile);
+30 -5
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useRef, useCallback } from "react"; import { useState, useRef, useCallback, useEffect } from "react";
import { useAnimation } from "framer-motion"; import { useAnimation } from "framer-motion";
import confetti from "canvas-confetti"; import confetti from "canvas-confetti";
import type { DrawnIdea } from "@/components/BlindboxDrawnHistory"; import type { DrawnIdea } from "@/components/BlindboxDrawnHistory";
@@ -23,8 +23,21 @@ export function useBlindboxDraw(
const boxControls = useAnimation(); const boxControls = useAnimation();
const confettiAliveRef = useRef(false); const confettiAliveRef = useRef(false);
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]); const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const animationFrameRef = useRef<number | null>(null);
const stopConfetti = useCallback(() => {
confettiAliveRef.current = false;
if (animationFrameRef.current !== null) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
timersRef.current.forEach(clearTimeout);
timersRef.current = [];
if (typeof confetti.reset === "function") confetti.reset();
}, []);
const fireConfetti = useCallback(() => { const fireConfetti = useCallback(() => {
stopConfetti();
const colors = ["#a855f7", "#6366f1", "#ec4899", "#f59e0b", "#10b981"]; const colors = ["#a855f7", "#6366f1", "#ec4899", "#f59e0b", "#10b981"];
confetti({ particleCount: 100, spread: 120, origin: { y: 0.4 }, colors, startVelocity: 45, ticks: 250 }); confetti({ particleCount: 100, spread: 120, origin: { y: 0.4 }, colors, startVelocity: 45, ticks: 250 });
confettiAliveRef.current = true; confettiAliveRef.current = true;
@@ -33,10 +46,21 @@ export function useBlindboxDraw(
if (Date.now() > end || !confettiAliveRef.current) return; if (Date.now() > end || !confettiAliveRef.current) return;
confetti({ particleCount: 3, angle: 60, spread: 55, origin: { x: 0, y: 0.6 }, colors, startVelocity: 35, ticks: 150 }); confetti({ particleCount: 3, angle: 60, spread: 55, origin: { x: 0, y: 0.6 }, colors, startVelocity: 35, ticks: 150 });
confetti({ particleCount: 3, angle: 120, spread: 55, origin: { x: 1, y: 0.6 }, colors, startVelocity: 35, ticks: 150 }); confetti({ particleCount: 3, angle: 120, spread: 55, origin: { x: 1, y: 0.6 }, colors, startVelocity: 35, ticks: 150 });
requestAnimationFrame(frame); animationFrameRef.current = requestAnimationFrame(frame);
}; };
timersRef.current.push(setTimeout(frame, 200)); timersRef.current.push(
}, []); setTimeout(() => {
animationFrameRef.current = requestAnimationFrame(frame);
}, 200),
);
}, [stopConfetti]);
useEffect(
() => () => {
stopConfetti();
},
[stopConfetti],
);
const handleDraw = async () => { const handleDraw = async () => {
if (poolCount === 0 || !profile || !room) { if (poolCount === 0 || !profile || !room) {
@@ -78,10 +102,11 @@ export function useBlindboxDraw(
}; };
const handleContinue = useCallback(() => { const handleContinue = useCallback(() => {
stopConfetti();
setPhase("pool"); setPhase("pool");
setRevealedIdea(null); setRevealedIdea(null);
setShowShareCard(false); setShowShareCard(false);
}, [setPhase]); }, [setPhase, stopConfetti]);
return { return {
revealedIdea, revealedIdea,
+9 -1
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useCallback, useRef } from "react"; import { useState, useCallback, useEffect, useRef } from "react";
import { useAnimation } from "framer-motion"; import { useAnimation } from "framer-motion";
import { useToast } from "@/hooks/useToast"; import { useToast } from "@/hooks/useToast";
import type { MyIdea } from "@/components/BlindboxMyIdeas"; import type { MyIdea } from "@/components/BlindboxMyIdeas";
@@ -51,6 +51,14 @@ export function useBlindboxIdeas(room: RoomInfo | null, profile: UserProfile | n
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]); const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
useEffect(() => {
const timers = timersRef.current;
return () => {
timers.forEach(clearTimeout);
timers.length = 0;
};
}, []);
const fetchIdeas = useCallback(async () => { const fetchIdeas = useCallback(async () => {
if (!room || !profile) return; if (!room || !profile) return;
try { try {
+5 -1
View File
@@ -37,7 +37,11 @@ export function useBlindboxRoom(code: string) {
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]); const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
useEffect(() => { useEffect(() => {
return () => { timersRef.current.forEach(clearTimeout); }; const timers = timersRef.current;
return () => {
timers.forEach(clearTimeout);
timers.length = 0;
};
}, []); }, []);
useEffect(() => { useEffect(() => {