DEBUG.WIKI
阿里云;从建站到云上业务;云服务器 · 数据库 · 对象存储;查看产品 →
搬瓦工;先看线路,再选 VPS;CN2 GIA · 对应线路套餐;查看线路 →
野草云;香港 200Mbps 峰值;不限月流量 · BGP VPS;查看套餐 →

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 可以完成;不能仅以进程这次没挂住作为唯一结果。