fix(drag): 中断/快速释放不再回滚,HMR 后中心列重定位兜底 (#247 #248) (#249)

#247 拖拽回退(v0.13.1 无此问题,unified-host 重构引入的回归):
- pointerup 恢复 v0.13.1 语义:以 up 事件坐标(指针最终位置)提交,
  不再优先 rAF pending —— 浏览器把快速 flick 的尾部合并进 up 事件时
  pending 是陈旧中间值,提交后会“看起来没拖到位”
- onPointerCancel 把事件(携带坐标)传给 abortDrag:此前的坐标兜底
  是死代码(handler 未传 event),pointercancel 且无 move 时必回滚
- abortDrag 无任何位置信息时采用最后应用尺寸(lastDragSize),不再
  显式回滚到拖动前值;pointercancel -> lostpointercapture 双发只提交
  一次(dragCommitted 在所有提交路径置位,第二次中断不再回滚)

#248 HMR 后底栏空白/输入框位移:
- #root 观察改为 childList+subtree:HMR 原地替换 centerCol 节点时
  childList-only 观察器不触发,ref/RO 挂在已分离节点上永久失效
- locate 只在列节点身份变化时测量(同节点缩放交给 ResizeObserver),
  rAF 防抖避免流式渲染 mutation 突发强制布局
- 列节点断开(isConnected=false)时丢弃引用,下次 tick 重新定位
- 1.5s 兜底重定位 interval:任何 HMR teardown/setup 时序都能在数秒内
  收敛(style 属性字节相同、观察器尚未挂上等盲区全部覆盖)

测试:
- e2e 新增三条回归:pointercancel 双发提交一次、无坐标 capture-lost
  采用最后应用宽度、中心列深层替换后底栏重定位追踪新几何
- 修复既有快速拖动测试的滑入动画竞态(动画中读 strip 盒子在视口外,
  指针事件打不到 strip)——此前依赖时序侥幸通过
- 全部在新启动的 scratch DSH 实例(e2e-mount,随机端口)上验证,
  未触碰本地 3080 实例;三条新用例对未修复代码均失败(回滚/不恢复)
This commit is contained in:
Atlas Zeng
2026-08-20 14:58:56 +08:00
committed by GitHub
parent 6c891514b5
commit e8d04980b4
2 changed files with 374 additions and 63 deletions
+271 -19
View File
@@ -29,7 +29,7 @@
import { mkdirSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test, expect, request, type APIRequestContext } from '@playwright/test'
import { test, expect, request, type APIRequestContext, type Locator, type Page } from '@playwright/test'
const BASE_URL = process.env.DSH_E2E_URL
if (!BASE_URL) {
@@ -304,24 +304,9 @@ test('a very fast width drag still commits the dragged position (no rollback on
})
const widthBefore = await readWidth()
const stripBox = await page.evaluate(() => {
const host = document.querySelector('[data-dsh-better-sidebar]')
if (host === null) return null
const boxes = [...host.querySelectorAll('*')]
.filter(el => getComputedStyle(el).cursor === 'col-resize')
.map(el => el.getBoundingClientRect())
.filter(r => r.width > 0 && r.height > 0)
.sort((a, b) => a.x - b.x)
const r = boxes[0]
if (r === undefined) return null
const varWidth = parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
return {
x: r.x, y: r.y, width: r.width, height: r.height,
varWidth: Number.isNaN(varWidth) ? 0 : varWidth,
innerWidth: window.innerWidth,
}
})
expect(stripBox, 'the width drag strip must be present').not.toBeNull()
// The panel slides in from the right on expand: read the strip box only
// AFTER it settles at the pushed layout edge (a mid-animation read sits
// off-viewport and the drag never reaches the strip).
await expect
.poll(async () => {
const box = await page.evaluate(() => {
@@ -338,6 +323,19 @@ test('a very fast width drag still commits the dragged position (no rollback on
return Math.abs(box.x - (box.innerWidth - varWidth)) <= 8
}, { timeout: 30_000 })
.toBe(true)
const stripBox = await page.evaluate(() => {
const host = document.querySelector('[data-dsh-better-sidebar]')
if (host === null) return null
const boxes = [...host.querySelectorAll('*')]
.filter(el => getComputedStyle(el).cursor === 'col-resize')
.map(el => el.getBoundingClientRect())
.filter(r => r.width > 0 && r.height > 0)
.sort((a, b) => a.x - b.x)
const r = boxes[0]
if (r === undefined) return null
return { x: r.x, y: r.y, width: r.width, height: r.height }
})
expect(stripBox, 'the width drag strip must be present').not.toBeNull()
const startX = stripBox!.x + stripBox!.width / 2
const startY = stripBox!.y + Math.min(120, stripBox!.height / 2 + 60)
@@ -359,3 +357,257 @@ test('a very fast width drag still commits the dragged position (no rollback on
await page.waitForTimeout(600)
expect(await readWidth(), 'the fast drag must not roll back after settling').toBeGreaterThan(widthBefore + 100)
})
/* ── issue #247: interrupted fast drags must not roll back ───────────── */
/** Dismiss whatever onboarding takeover is present (same dance as above). */
async function dismissOnboarding(page: Page): Promise<void> {
try {
await expect
.poll(() => page.getByRole('button', { name: /^(Continue|Configure later)$/ }).count(), { timeout: 60_000 })
.toBeGreaterThan(0)
} catch {
console.warn('[e2e-drag] no onboarding takeover appeared; proceeding')
}
for (let round = 0; round < 8; round++) {
let dismissed = false
for (const name of ['Continue', 'Configure later']) {
const button = page.getByRole('button', { name, exact: true }).first()
if ((await button.count()) === 0) continue
try {
await button.click({ timeout: 4_000 })
dismissed = true
await page.waitForTimeout(1_000)
} catch {
// Masked by a takeover stacked above; retry in the next round.
}
}
if (!dismissed) break
}
}
/** Open the sidebar panel and wait for the layout push to go live. */
async function expandSidebar(page: Page, sidebar: Locator): Promise<void> {
const expandButton = sidebar.getByRole('button', { name: 'Expand sidebar' })
await expect(expandButton, 'the collapsed toggle cluster must offer the expand button').toHaveCount(1)
await expandButton.click()
await expect
.poll(async () => {
const value = await page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
return value !== '' && value !== '0px'
}, { timeout: 90_000 })
.toBe(true)
}
/** Locate the width drag strip (the leftmost col-resize element inside the
* sidebar host), wait for it to settle at the pushed layout edge (the
* panel slides in from the right on expand — a strip read during the
* animation sits off-viewport and no pointer event ever reaches it), then
* read its FINAL box and return a drag start point on it. */
async function settleWidthStrip(page: Page): Promise<{ startX: number; startY: number }> {
await expect
.poll(async () => {
const box = await page.evaluate(() => {
const host = document.querySelector('[data-dsh-better-sidebar]')
const el = [...host!.querySelectorAll('*')]
.filter(e => getComputedStyle(e).cursor === 'col-resize')
.map(e => e.getBoundingClientRect())
.filter(r => r.width > 0 && r.height > 0)
.sort((a, b) => a.x - b.x)[0]
return el === undefined ? null : { x: el.x + el.width, innerWidth: window.innerWidth }
})
if (box === null) return false
const varWidth = await page.evaluate(() => parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width')) || 0)
return Math.abs(box.x - (box.innerWidth - varWidth)) <= 8
}, { timeout: 30_000 })
.toBe(true)
const stripBox = await page.evaluate(() => {
const host = document.querySelector('[data-dsh-better-sidebar]')
if (host === null) return null
const boxes = [...host.querySelectorAll('*')]
.filter(el => getComputedStyle(el).cursor === 'col-resize')
.map(el => el.getBoundingClientRect())
.filter(r => r.width > 0 && r.height > 0)
.sort((a, b) => a.x - b.x)
const r = boxes[0]
if (r === undefined) return null
return { x: r.x, y: r.y, width: r.width, height: r.height }
})
expect(stripBox, 'the width drag strip must be present').not.toBeNull()
return {
startX: stripBox!.x + stripBox!.width / 2,
startY: stripBox!.y + Math.min(120, stripBox!.height / 2 + 60),
}
}
/** Dispatch a synthetic pointer event (pointercancel / lostpointercapture)
* onto the width strip. Synthetic events bypass the real pointer-capture
* pipeline, which is exactly the point: the handlers must survive streams
* that end without a component-visible pointerup. */
async function dispatchPointerOnStrip(
page: Page,
type: 'pointercancel' | 'lostpointercapture',
init: { clientX?: number; clientY?: number },
): Promise<void> {
await page.evaluate(({ type, init }) => {
const host = document.querySelector('[data-dsh-better-sidebar]')
if (host === null) throw new Error('sidebar host missing')
const el = [...host.querySelectorAll<HTMLElement>('*')]
.filter(el => getComputedStyle(el).cursor === 'col-resize')
.map(el => ({ el, r: el.getBoundingClientRect() }))
.filter(({ r }) => r.width > 0 && r.height > 0)
.sort((a, b) => a.r.x - b.r.x)[0]?.el
if (el === undefined) throw new Error('width drag strip not found')
el.dispatchEvent(new PointerEvent(type, {
pointerId: 1,
bubbles: true,
cancelable: true,
pointerType: 'mouse',
isPrimary: true,
...init,
}))
}, { type, init })
}
test('an interrupted fast drag (pointercancel → lostpointercapture) keeps the dragged width (issue #247)', async ({ page }) => {
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' })
await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 })
const sidebar = page.locator('[data-dsh-better-sidebar]')
await expect(sidebar).toBeAttached({ timeout: 90_000 })
await dismissOnboarding(page)
await expandSidebar(page, sidebar)
const readWidth = (): Promise<number> => page.evaluate(() => {
const value = parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
return Number.isNaN(value) ? 0 : value
})
const widthBefore = await readWidth()
const { startX, startY } = await settleWidthStrip(page)
// Ultra-fast flick whose stream the browser then CANCELS (touchpad /
// gesture interception): pointercancel carries the final position and
// lostpointercapture follows it. The double fire must commit ONCE — the
// second interrupt event used to roll the drag back to the pre-drag
// width (the abort path never marked the drag committed).
await page.mouse.move(startX, startY)
await page.mouse.down()
await page.mouse.move(startX - 140, startY, { steps: 1 })
await dispatchPointerOnStrip(page, 'pointercancel', { clientX: startX - 140, clientY: startY })
await dispatchPointerOnStrip(page, 'lostpointercapture', {})
await expect
.poll(async () => await readWidth(), { timeout: 10_000 })
.toBeGreaterThan(widthBefore + 100)
await page.waitForTimeout(600)
expect(await readWidth(), 'the interrupted fast drag must not roll back after settling').toBeGreaterThan(widthBefore + 100)
// Release the real pointer stream the synthetic events bypassed.
await page.mouse.up()
})
test('a capture-lost drag with no usable coordinates keeps the last applied width (issue #247)', async ({ page }) => {
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' })
await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 })
const sidebar = page.locator('[data-dsh-better-sidebar]')
await expect(sidebar).toBeAttached({ timeout: 90_000 })
await dismissOnboarding(page)
await expandSidebar(page, sidebar)
const readWidth = (): Promise<number> => page.evaluate(() => {
const value = parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
return Number.isNaN(value) ? 0 : value
})
const widthBefore = await readWidth()
const { startX, startY } = await settleWidthStrip(page)
// A flick whose move DID reach the DOM (the rAF flushed it), followed by
// capture loss with NO coordinates (lostpointercapture does not carry a
// position). The abort must adopt the last applied size — the old code
// explicitly reverted the DOM to the pre-drag width here.
await page.mouse.move(startX, startY)
await page.mouse.down()
await page.mouse.move(startX - 120, startY, { steps: 1 })
await page.waitForTimeout(100) // let the rAF consume the pending write
await dispatchPointerOnStrip(page, 'lostpointercapture', {})
await expect
.poll(async () => await readWidth(), { timeout: 10_000 })
.toBeGreaterThan(widthBefore + 80)
await page.waitForTimeout(600)
expect(await readWidth(), 'the no-coordinate capture loss must not roll back after settling').toBeGreaterThan(widthBefore + 80)
await page.mouse.up()
})
/* ── issue #248: HMR center-column replacement must be re-located ────── */
test('bottom panel re-locates a center column replaced deep inside #root (issue #248)', async ({ page }) => {
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' })
await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 })
const sidebar = page.locator('[data-dsh-better-sidebar]')
await expect(sidebar).toBeAttached({ timeout: 90_000 })
await dismissOnboarding(page)
await expandSidebar(page, sidebar)
// Open the bottom panel; it must become visible with the center column
// measured (its right edge tracking the column's right edge).
const bottomExpand = sidebar.getByRole('button', { name: 'Expand bottom panel' })
await expect(bottomExpand, 'the toggle cluster must offer the bottom-panel expand button').toHaveCount(1)
await bottomExpand.click()
await expect
.poll(async () => {
const value = await page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-sidebar-height'))
return value !== '' && value !== '0px'
}, { timeout: 90_000 })
.toBe(true)
const probe = (): Promise<{ visible: boolean; drift: number } | null> => page.evaluate(() => {
const el = document.querySelector('[data-dsh-better-sidebar] [data-dsh-bottom-panel]')
const col = document.querySelector('#root [data-slot="conversation"]')?.parentElement
if (el === null || col == null) return null
const panel = el.getBoundingClientRect()
const center = col.getBoundingClientRect()
return {
visible: getComputedStyle(el).visibility !== 'hidden',
drift: Math.abs(panel.right - center.right),
}
})
await expect
.poll(async () => {
const p = await probe()
return p !== null && p.visible && p.drift <= 8
}, { timeout: 30_000 })
.toBe(true)
// Simulate the HMR re-render: the shell swaps the center column node
// DEEP inside #root (a mutation the old childList-only watcher never
// saw — #root's own children don't change), first with a zero-size stub
// (mid-teardown), then with a re-created column of different geometry.
await page.evaluate(() => {
const col = document.querySelector('#root [data-slot="conversation"]')?.parentElement
if (col == null || col.parentElement === null) throw new Error('center column not found')
const stub = document.createElement('div')
const slot = document.createElement('div')
slot.setAttribute('data-slot', 'conversation')
stub.appendChild(slot)
col.parentElement.replaceChild(stub, col)
})
await page.evaluate(() => {
const col = document.querySelector('#root [data-slot="conversation"]')?.parentElement
if (col == null || col.parentElement === null) throw new Error('center column stub not found')
const fresh = document.createElement('div')
fresh.style.width = '300px'
fresh.style.height = '200px'
const slot = document.createElement('div')
slot.setAttribute('data-slot', 'conversation')
fresh.appendChild(slot)
col.parentElement.replaceChild(fresh, col)
})
// The locate chain (subtree watcher / html-style watcher / 1.5s retry)
// must converge on the new column within a couple of seconds; the old
// code kept observing the detached column and never recovered.
await expect
.poll(async () => {
const p = await probe()
return p !== null && p.visible && p.drift <= 8
}, { timeout: 15_000 })
.toBe(true)
})