From 5e99034c87071b0e60319d317f40597555313443 Mon Sep 17 00:00:00 2001 From: Ryuu-64 <61548752+Ryuu-64@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:57:13 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=85=B1=E4=BA=AB=E6=82=AC=E6=B5=AE?= =?UTF-8?q?=E5=AE=B9=E5=99=A8=E7=9A=84=E7=9B=91=E5=90=AC=E5=99=A8=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E9=9A=8F=E9=9D=A2=E6=9D=BF=E9=87=8D=E5=BB=BA=E8=80=8C?= =?UTF-8?q?=E6=97=A0=E7=95=8C=E7=B4=AF=E7=A7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 与 #65、以及「跨间隙保活」那个 PR 均正交。#65 引入归属模型解决「宿主 layout mutation 用 updateChip 抹掉面板」与「定时器跨面板误杀」;本 PR 只处理 「监听器挂在共享容器上、随面板重建累积」这一条。 --- client.js | 40 ++++++++--- test/shared-listeners.test.mjs | 127 +++++++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+), 8 deletions(-) create mode 100644 test/shared-listeners.test.mjs diff --git a/client.js b/client.js index ab37786..5e723fe 100644 --- a/client.js +++ b/client.js @@ -1734,6 +1734,23 @@ window.__ModuleLoader__.load({ var tipLayer = document.createElement('div') tipLayer.setAttribute('data-annotation-tip-layer', '') document.body.appendChild(tipLayer) + // 共享容器的监听器只注册一次(见下方 sharedTipMouseEnter / sharedTipMouseLeave)。 + // tipLayer 是长期存活的 body 级单例,而气泡标签与回复芯片会被反复重建:原先 + // 每个面板在 mouseenter 里都往它追加一对监听器,却从不移除,于是每重建一代 + // 就净增两个闭包(持有已废弃的 grace 定时器与被移出 DOM 的触发元素)。 + // 这里改为两处固定监听器 + 一个「当前面板的关闭钩子」指针,由面板在 mouseenter + // 时登记,关闭或关闭被取消后清空。 + var tipActiveHide = null + function sharedTipMouseEnter() { + if (tipActiveHide === null) return + tipActiveHide.keep() + } + function sharedTipMouseLeave() { + if (tipActiveHide === null) return + tipActiveHide.hide() + } + tipLayer.addEventListener('mouseenter', sharedTipMouseEnter) + tipLayer.addEventListener('mouseleave', sharedTipMouseLeave) var observedComposer = null var composerObserver = typeof ResizeObserver === 'function' ? new ResizeObserver(onLayoutChange) @@ -1778,16 +1795,23 @@ window.__ModuleLoader__.load({ if (hoverGrace !== null) clearTimeout(hoverGrace) hoverGrace = setTimeout(function () { hoverGrace = null + releaseActiveTip() tipLayer.textContent = '' }, 250) } function cancelHide() { if (hoverGrace !== null) { clearTimeout(hoverGrace); hoverGrace = null } } - chipLayer.addEventListener('mouseenter', function () { cancelHide(); showChipTip() }) + /** 面板已真正关闭时解除登记:此后共享容器上的 mouseenter/leave 不再有对象可派发。 */ + function releaseActiveTip() { + tipActiveHide = null + } + chipLayer.addEventListener('mouseenter', function () { + cancelHide() + showChipTip() + tipActiveHide = { keep: cancelHide, hide: scheduleHide } + }) chipLayer.addEventListener('mouseleave', scheduleHide) - tipLayer.addEventListener('mouseenter', cancelHide) - tipLayer.addEventListener('mouseleave', scheduleHide) function showChipTip() { if (ui.quotes.length === 0) return @@ -2002,6 +2026,7 @@ window.__ModuleLoader__.load({ ;(function (list) { tag.addEventListener('mouseenter', function () { tipLayer.textContent = '' + tipActiveHide = { keep: bubbleKeep, hide: bubbleHide } var el = document.createElement('div') el.className = 'dsh-ann-tip' el.style.cssText = 'position:fixed;z-index:1160;width:300px;max-width:calc(100vw - 16px);padding:10px 12px;border-radius:12px;border:1px solid var(--dsw-alias-border-inverted);background:var(--dsw-specific-menu,#2c2c2e);box-shadow:var(--dsw-shadow-lv3);font-family:var(--dsw-font-family,system-ui);font-size:12px;color:var(--dsw-alias-label-primary);' @@ -2044,15 +2069,15 @@ window.__ModuleLoader__.load({ if (bubbleGrace !== null) clearTimeout(bubbleGrace) bubbleGrace = setTimeout(function () { bubbleGrace = null + releaseActiveTip() tipLayer.textContent = '' }, 250) } function bubbleKeep() { if (bubbleGrace !== null) { clearTimeout(bubbleGrace); bubbleGrace = null } } + // 只登记关闭钩子,不再往共享 tipLayer 上追加监听器(见 tipActiveHide 注释) tag.addEventListener('mouseleave', bubbleHide) - tipLayer.addEventListener('mouseenter', bubbleKeep) - tipLayer.addEventListener('mouseleave', bubbleHide) })(items) tag.__annotationItems = items bubble.appendChild(tag) @@ -2175,13 +2200,14 @@ window.__ModuleLoader__.load({ var grace = null function hide() { if (grace !== null) clearTimeout(grace) - grace = setTimeout(function () { grace = null; tipLayer.textContent = '' }, 250) + grace = setTimeout(function () { grace = null; releaseActiveTip(); tipLayer.textContent = '' }, 250) } function keep() { if (grace !== null) { clearTimeout(grace); grace = null } } chip.addEventListener('mouseenter', function () { tipLayer.textContent = '' + tipActiveHide = { keep: keep, hide: hide } var el = document.createElement('div') el.className = 'dsh-ann-tip' el.style.cssText = 'position:fixed;z-index:1160;width:320px;max-width:calc(100vw - 16px);padding:10px 12px;border-radius:12px;border:1px solid var(--dsw-alias-border-inverted);background:var(--dsw-specific-menu,#2c2c2e);box-shadow:var(--dsw-shadow-lv3);font-family:var(--dsw-font-family,system-ui);font-size:12px;color:var(--dsw-alias-label-primary);' @@ -2218,8 +2244,6 @@ window.__ModuleLoader__.load({ el.style.width = w2 + 'px' }) chip.addEventListener('mouseleave', hide) - tipLayer.addEventListener('mouseenter', keep) - tipLayer.addEventListener('mouseleave', hide) return chip } diff --git a/test/shared-listeners.test.mjs b/test/shared-listeners.test.mjs new file mode 100644 index 0000000..ebdeee1 --- /dev/null +++ b/test/shared-listeners.test.mjs @@ -0,0 +1,127 @@ +// 共享悬浮容器的监听器泄漏回归测试。 +// +// 背景:`tipLayer` 是长期存活的 body 级单例(只在插件销毁时 remove),而气泡标签 +// (attachBubbleTag)与回复芯片(makeReplyChip)会被反复重建 —— 消息重渲染、语言 +// 切换、切会话回来都会让它们换一代。改动前每个面板都在自己的 mouseenter 里往 +// `tipLayer` 追加一对监听器,却从不移除: +// +// tag.addEventListener('mouseleave', bubbleHide) +// tipLayer.addEventListener('mouseenter', bubbleKeep) // ← 每代 +1 +// tipLayer.addEventListener('mouseleave', bubbleHide) // ← 每代 +1 +// +// 于是监听器无界累积,每个闭包还持有已废弃的 grace 定时器与被移出 DOM 的触发元素; +// 关闭决策不再由「当前面板」决定,而是由历史上任意一代的定时器决定。 +// +// 修复后共享容器上只保留两处固定监听器,通过 tipActiveHide 指针派发到当前面板。 + +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' +import vm from 'node:vm' + +const here = dirname(fileURLToPath(import.meta.url)) +const source = readFileSync(join(here, '..', 'client.js'), 'utf8') + +/** 取出一个顶层 function 的完整源码(花括号配平)。 */ +function extractFunction(name) { + const m = new RegExp(`function ${name}\\s*\\(`).exec(source) + if (m === null) throw new Error(`client.js 里找不到 function ${name}`) + const open = source.indexOf('{', m.index) + let depth = 0 + for (let i = open; i < source.length; i++) { + if (source[i] === '{') depth++ + else if (source[i] === '}') { + depth-- + if (depth === 0) return source.slice(m.index, i + 1) + } + } + throw new Error(`function ${name} 花括号不配平`) +} + +test('共享容器只注册两处监听器(气泡标签/回复芯片各自 +2 的写法已移除)', () => { + const raw = source.match(/tipLayer\.addEventListener\(/g) ?? [] + assert.equal(raw.length, 2, 'tipLayer 应只保留 mouseenter / mouseleave 两处固定监听器') + assert.match(source, /tipLayer\.addEventListener\('mouseenter', sharedTipMouseEnter\)/) + assert.match(source, /tipLayer\.addEventListener\('mouseleave', sharedTipMouseLeave\)/) +}) + +test('三类面板都不再往共享容器追加监听器', () => { + // 面板只挂自己的触发元素监听,并且只做登记 + assert.match(source, /tag\.addEventListener\('mouseleave', bubbleHide\)/) + assert.match(source, /chip\.addEventListener\('mouseleave', hide\)/) + assert.match(source, /chipLayer\.addEventListener\('mouseleave', scheduleHide\)/) + // 旧写法(每代面板各挂一对到 tipLayer)应彻底消失 + assert.doesNotMatch(source, /tipLayer\.addEventListener\('mouseenter', (bubbleKeep|keep|cancelHide)\)/) + assert.doesNotMatch(source, /tipLayer\.addEventListener\('mouseleave', (bubbleHide|hide|scheduleHide)\)/) +}) + +test('每类面板都在 mouseenter 时登记关闭钩子', () => { + const registrations = source.match(/tipActiveHide = \{ keep: [^}]+ \}/g) ?? [] + assert.equal(registrations.length, 3, '三类面板各应登记一次(胶囊 / 气泡标签 / 回复芯片)') + assert.match(source, /tipActiveHide = \{ keep: cancelHide, hide: scheduleHide \}/) + assert.match(source, /tipActiveHide = \{ keep: bubbleKeep, hide: bubbleHide \}/) + assert.match(source, /tipActiveHide = \{ keep: keep, hide: hide \}/) +}) + +test('面板关闭后解除登记,避免残留钩子被继续派发', () => { + assert.match(source, /function releaseActiveTip\(\) \{\s*tipActiveHide = null\s*\}/) + // 三处宽限到点的关闭都要解除登记 + const releases = source.match(/releaseActiveTip\(\)/g) ?? [] + assert.ok(releases.length >= 4, `应有 1 处定义 + 至少 3 处调用,实际 ${releases.length}`) +}) + +// ---- 行为:共享监听器确实按「当前登记的面板」派发 ---- + +function makeDispatcher() { + const sandbox = { console } + vm.createContext(sandbox) + vm.runInContext([ + 'var tipActiveHide = null', + extractFunction('sharedTipMouseEnter'), + extractFunction('sharedTipMouseLeave'), + extractFunction('releaseActiveTip'), + 'globalThis.__env = { sharedTipMouseEnter, sharedTipMouseLeave, releaseActiveTip,' + + ' register(h) { tipActiveHide = h }, get registered() { return tipActiveHide } }', + ].join('\n'), sandbox) + return sandbox.__env +} + +test('mouseenter / mouseleave 派发到当前登记的面板', () => { + const env = makeDispatcher() + const calls = [] + env.register({ keep: () => calls.push('keep'), hide: () => calls.push('hide') }) + env.sharedTipMouseEnter() + env.sharedTipMouseLeave() + assert.deepEqual(calls, ['keep', 'hide']) +}) + +test('面板换代后派发到新面板,不会误触上一代', () => { + const env = makeDispatcher() + const calls = [] + env.register({ keep: () => calls.push('A-keep'), hide: () => calls.push('A-hide') }) + env.register({ keep: () => calls.push('B-keep'), hide: () => calls.push('B-hide') }) + env.sharedTipMouseEnter() + env.sharedTipMouseLeave() + assert.deepEqual(calls, ['B-keep', 'B-hide'], '只应派发到最近登记的面板') +}) + +test('没有登记时共享监听器是空操作(不会抛错)', () => { + const env = makeDispatcher() + env.releaseActiveTip() + assert.equal(env.registered, null) + env.sharedTipMouseEnter() + env.sharedTipMouseLeave() + assert.equal(env.registered, null) +}) + +test('releaseActiveTip 之后不再派发', () => { + const env = makeDispatcher() + const calls = [] + env.register({ keep: () => calls.push('keep'), hide: () => calls.push('hide') }) + env.releaseActiveTip() + env.sharedTipMouseEnter() + env.sharedTipMouseLeave() + assert.deepEqual(calls, [], '解除登记后不应再派发') +})