Happy DOM 切回真实计时器后,setTimeout 0 一直不执行
Bun1.4.3取消待执行假计时器后,共享Happy DOM Window可能留下失效的零延迟批次。调整受控清理顺序或隔离DOM环境,保留真正的回调验收。
Bun 测试里的前一个用例启用了 fake timers,安排一次 window.setTimeout(fn, 0),随后切回 real timers。后一个用例只是等待零延迟回调,却一直不结束。若两个用例共享 Happy DOM 的全局 Window,需要检查前一个用例是否留下了失效的零延迟任务批次。
这一具体组合涉及 Bun 1.4.3 与 Happy DOM 20.14.5;当前正式 Happy DOM 21.0.4 仍保留相同的零延迟分组结构。不能据此认定 Jest、Vitest 或其他运行时切换计时器时都采用相同行为。
一个真实计时器承载多次零延迟调用
Happy DOM 为减少底层计时器数量,把零延迟回调放入共享数组,只安排一个底层 setTimeout。底层回调触发时,才会取出该数组,并把批次状态恢复为 null。
如果测试运行时在这个底层回调触发前取消了它,Window 内的数组却仍存在。下一次零延迟调用看见“已有批次”,只把新回调加入旧数组,不再安排底层计时器。于是后一个测试即使已经切回真实时间,也可能继续等待一个不会被处理的批次。
这与回调自身抛错、Promise 没有活跃句柄导致 Node 退出不同。单纯延长测试超时不会重新安排已经消失的底层计时器。
在恢复真实时间前结束受控的假计时任务
若用例只安排了有限、可预期的计时器,按实际 runner 支持的 API 先执行这些待处理回调,再恢复真实计时器。对上述只有有限、受控零延迟任务的 Bun 用例,可先调用 jest.runOnlyPendingTimers(),再调用 jest.useRealTimers()。清理顺序必须发生在同一个用例或其可靠的清理钩子中,不能指望下一个测试帮前一个补跑。
不要对包含无限 interval、递归 setTimeout 或业务副作用的队列盲目执行“全部计时器”。应先让被测组件完成自己的取消和销毁,再决定哪些回调可以安全推进。只调用 clearAllTimers 可能仍然取消底层批次计时器,不能把它当成已经重置 Window 内部状态。
需要在 pending 状态下切换计时器的测试,可为它安排独立的 DOM 环境和生命周期,避免把这个 Window 继续借给其他用例。销毁与重建必须沿测试环境支持的入口完成;不要直接修改 Happy DOM 的私有字段或覆盖全局 setTimeout,破坏运行时自己的计时管理。
分开验证零延迟路径与计时器隔离
保留最小的两个用例:前一个在 fake timers 下安排零延迟回调,后一个在 real timers 下等待新的零延迟回调。分别检查正常清理顺序、独立环境以及不切换计时器的对照,确认后一个回调真正执行,而不是删除了等待断言。
把零改成 10 毫秒会绕开当前的零延迟分组,却也改变了被测时序,不能据此认定底层状态已恢复。若应用本来依赖零延迟先后顺序,应在正确隔离的环境里保留这项检查。


