fake-timers 已清除 interval,下一次 tick 却一直不返回
fake-timers 15.4.0 在伪微任务清除已选定的 interval 后仍执行回调,可能留下堆残项并使下一次 tick 卡住。保留业务清理,检查调用前的失效保护与完整回归。
interval 的清理日志已经出现,clock.countTimers() 看起来也不对,下一次 clock.tick() 却一直不返回。使用 fake-timers 15.4.0 时,要留意清理是不是来自前一个 timer 中登记的伪微任务。
这一顺序很具体:tick 先选出下一项 timer,随后排空 fake queueMicrotask 或 nextTick;其中一项清理刚选中的 timer,代码却继续调用 callTimer。timeout 可能多执行一次;interval 则可能重新进入内部堆,虽然 timer 映射里已经没有它,之后的 tick 持续碰到这个残留项。
调用前已经排好的微任务不一定触发
tick 开始时会先排空一批工作。只在调用 tick 前登记一次清理,可能无法保留“选中 timer 后才清除”的时间窗口。需要核对原来那段 timer 回调、微任务清理和下一次到期之间的关系。
React effect 清理只是可能产生这种顺序的一种场景,复现本身不要求 React。组件已经正确调用 clearInterval,并不能排除这里的计时器结构失配;反过来,组件根本没有清理,也不能靠改 fake-timers 自动补上。
保留正确清理,处理计时器失效检查
对必须保留这条调度顺序的测试,可以在锁定的 15.4.0 包中采用受审查的局部补丁:在 callTimer 的开头再次确认所选项仍存在,失效时直接返回。下面只表示该内部函数的增量,不是业务脚本可直接粘贴的函数:
function callTimer(clock, timer) {
+ if (!hasTimer(clock, timer.id)) {
+ return;
+ }
if (typeof timer.interval === "number") {
它位于 interval 重新入堆之前,也覆盖已失效 timeout。通过项目既有的依赖补丁方式保存,并让相同版本的完整测试检查其他 callTimer 入口;不要在测试运行一半时猴子补丁内部 clock,也不要直接删改 heap 数组。
如果测试本来不需要模拟微任务,可评估仅伪造实际依赖的定时器,让微任务仍由原生队列处理。但这会改变推进方式和测试覆盖,不能当作通用修复。真正需要验证微任务中的取消时序时,仍应保留相应样本。
保留变更前的锁文件与包来源;撤回时移除该局部补丁,按原锁文件恢复依赖,再重验原来的调度与清理行为。
提高 loopLimit 未必能拦住它
残留项可能在“检查发现 timer 已不存在”的分支反复被选中,并不真正调用回调。因此基于已执行回调数的 loopLimit 不能保证终止这次循环;同线程的测试超时也可能没有机会触发。
不要关闭 clearInterval,或让组件不再卸载,来换取测试结束。那样会删除本来要验证的业务行为。无法立即采用补丁时,可先把危险用例隔离在可终止的测试进程,保留失败记录,避免一个同步循环拖住整批任务。
截至 2026 年 10 月 26 日,正式 15.4.0 的 callTimer 尚无上述失效检查。验收应分别确认被清除 timeout 不再回调、interval 不再重入、正常 interval 仍按预期执行,以及之后的 tick 可以完成;不能仅以进程这次没挂住作为唯一结果。


