Skip to content

fix: 共享悬浮容器的监听器不再随面板重建而无界累积 - #67

Merged
taekchef merged 1 commit into
omdsh-dev:mainfrom
Ryuu-64:fix/shared-tip-listeners
Sep 30, 2026
Merged

taekchef merged 1 commit into
omdsh-dev:mainfrom
Ryuu-64:fix/shared-tip-listeners

Conversation

@Ryuu-64

@Ryuu-64 Ryuu-64 commented Sep 24, 2026

Copy link
Copy Markdown

现象

长会话、消息反复重渲染(流式结束后重新装饰、切换语言、切会话回来)之后,悬浮面板的关闭行为变得不可预测:有时刚出现就被关掉,有时关不掉。

根因

tipLayer 是长期存活的 body 级单例(只在插件销毁时 remove()),而气泡标签(attachBubbleTag)与回复芯片(makeReplyChip)是会被反复重建的——消息重渲染、语言切换、切会话回来都会让它们换一代。

但改动前,每个面板都在自己的 mouseenter 里往共享容器追加一对监听器,却从不移除:

// 气泡标签
tag.addEventListener('mouseleave', bubbleHide)
tipLayer.addEventListener('mouseenter', bubbleKeep)   // ← 每代 +1
tipLayer.addEventListener('mouseleave', bubbleHide)    // ← 每代 +1

// 回复芯片
chip.addEventListener('mouseleave', hide)
tipLayer.addEventListener('mouseenter', keep)          // ← 每代 +1
tipLayer.addEventListener('mouseleave', hide)          // ← 每代 +1

于是面板每次重建就净增两个订阅者,它们闭包持有已废弃的 grace 定时器与已被移出 DOM 的触发元素:

  • 泄漏随会话长度无界增长;
  • 关闭决策不再由「当前面板」决定,而是由历史上任意一代的定时器决定——这也是「面板刚出现就被关掉」的来源之一。

改动

共享容器上只保留两处固定监听器,通过一个「当前面板的关闭钩子」指针派发:

// 只注册一次
var tipActiveHide = null
function sharedTipMouseEnter() { if (tipActiveHide !== null) tipActiveHide.keep() }
function sharedTipMouseLeave() { if (tipActiveHide !== null) tipActiveHide.hide() }
tipLayer.addEventListener('mouseenter', sharedTipMouseEnter)
tipLayer.addEventListener('mouseleave', sharedTipMouseLeave)

// 面板在 mouseenter 时登记(三类面板各一处)
tipActiveHide = { keep: cancelHide, hide: scheduleHide }   // 输入框旁胶囊
tipActiveHide = { keep: bubbleKeep, hide: bubbleHide }     // 气泡标签
tipActiveHide = { keep: keep,       hide: hide }           // 回复芯片

// 面板真正关闭时解除登记(三处宽限到点的关闭都调用)
function releaseActiveTip() { tipActiveHide = null }

同时删除三类面板中各自的 tipLayer.addEventListener。

为什么需要 releaseActiveTip

如果只在 mouseenter 登记而从不解除,tipActiveHide 会一直指向最后一代面板:该面板被移出 DOM 后,指针再划过共享容器仍会去调用它的 hide()。关闭时解除登记,共享监听器就退化为空操作。

对外行为不变

指针进入面板仍然取消关闭;离开仍按各面板自己的 250ms 宽限关闭。本 PR 不改变清理策略、不引入归属模型。

回归测试

新增 test/shared-listeners.test.mjs(8 条)。结构断言 + 把 client.js 里真实的 sharedTipMouseEnter / sharedTipMouseLeave / releaseActiveTip 抽到 node:vm 沙箱里按行为验证:

用例 断言
共享容器监听器数量 恰好 2 处,且为 sharedTipMouseEnter / sharedTipMouseLeave
三类面板旧写法 tipLayer.addEventListener('mouseenter', bubbleKeep|keep|cancelHide) 等已彻底消失
登记点 三类面板各登记一次 tipActiveHide = { keep, hide }
解除登记 存在 releaseActiveTip() 定义且三处关闭都调用
派发目标 mouseenter → keep(),mouseleave → hide()
面板换代 派发到最近登记的那一代,不误触上一代
无登记 共享监听器为空操作,不抛错
解除登记后 不再派发

反向验证——同一套用例跑上游原版 client.js:8/8 失败;本分支 8/8 通过。

$ node --test test/shared-listeners.test.mjs
# tests 8
# pass 8
# fail 0

与其它 PR 的关系

正交,可各自独立合并。

DSH Desktop 2.0.11 / 内核 0.1.5-rc.2 / Windows 11 实测:改动前后功能表现一致,差别在于共享容器上的监听器数量不再随重渲染增长。

`tipLayer` 是长期存活的 body 级单例(只在插件销毁时 remove),而气泡标签
(attachBubbleTag)与回复芯片(makeReplyChip)会被反复重建 —— 消息重渲染、语言
切换、切会话回来都会让它们换一代。改动前每个面板都在自己的 mouseenter 里往
`tipLayer` 追加一对监听器,却从不移除:

    tag.addEventListener('mouseleave', bubbleHide)
    tipLayer.addEventListener('mouseenter', bubbleKeep)   // ← 每代 +1
    tipLayer.addEventListener('mouseleave', bubbleHide)   // ← 每代 +1

回复芯片同理(keep / hide)。于是每次重建净增两个闭包,它们持有已废弃的 grace
定时器与已被移出 DOM 的触发元素;关闭决策不再由「当前面板」决定,而是由历史上
任意一代的定时器决定 —— 这既让面板的关闭时机变得不可预测,也随会话变长持续泄漏。

改动:
- 共享容器上只注册两处固定监听器 sharedTipMouseEnter / sharedTipMouseLeave;
- 新增 `tipActiveHide` 指针:由面板在 mouseenter 时登记 `{ keep, hide }`,
  共享监听器按它派发;
- 新增 releaseActiveTip():面板真正关闭时解除登记(三处宽限到点的关闭都调用),
  避免残留钩子仍被派发;
- 移除三类面板中各自的 tipLayer.addEventListener。

对外行为保持不变:指针进入面板仍会取消关闭,离开仍会按各自的 250ms 宽限关闭。

回归测试 test/shared-listeners.test.mjs(8 条):结构断言「tipLayer 上恰好两处
监听器」+ 把真实的 sharedTipMouseEnter / sharedTipMouseLeave / releaseActiveTip
抽到 vm 沙箱里按行为验证派发目标、面板换代、无登记空操作、解除登记后不再派发。

同一套用例跑上游原版 client.js:8/8 失败;本分支 8/8 通过。

注:本 PR 与 omdsh-dev#65、以及「跨间隙保活」那个 PR 均正交。omdsh-dev#65 引入归属模型解决「宿主
layout mutation 用 updateChip 抹掉面板」与「定时器跨面板误杀」;本 PR 只处理
「监听器挂在共享容器上、随面板重建累积」这一条。
@taekchef

Copy link
Copy Markdown
Collaborator

感谢 @Ryuu-64 的精准定位——tipLayer 作为 body 级单例,被每代重建的面板追加监听器而无界累积,这个泄漏在长会话下确实会持续恶化。

情况说明:你的 fork 未开放维护者推送,无法把合并提交直接推上你的分支,因此我们把「你的原始提交 + 与 main 的合并解决」一起快进推入了 main(3ab6530)——你的提交 5e99034 完整进入主仓历史,本 PR 已被标记为 Merged,署名不变。

合并中做的适配:与 #65 引入的 tipOwner 归属清空机制正交并存——tipActiveHide 管监听器泄漏(每次重建不再净增闭包),tipOwner 管「无归属清空误杀正在观看的面板」;所有 presentTip/clearTip 归属配对(chipLayer/tag/reply chip)保持不变,releaseActiveTip 挂在每个真实关闭路径上。测试 41/41 全绿,CHANGELOG 已注明方案来自你。

接下来会以同样方式处理你的 #66(实时指针判定),它会在本次改动之上 rebase。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants