Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 32 additions & 8 deletions client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);'
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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);'
Expand Down Expand Up @@ -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
}

Expand Down
127 changes: 127 additions & 0 deletions test/shared-listeners.test.mjs
Original file line number Diff line number Diff line change
@@ -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, [], '解除登记后不应再派发')
})