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
+103 -44
View File
@@ -411,6 +411,14 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
if (draggingRef.current) return
const col = centerColRef.current
if (col === null) return
if (!col.isConnected) {
// The observed column was detached (HMR re-render swapped the node
// in place): its rect is stale garbage. Drop the ref — the locate
// chain re-runs on the next mutation/interval tick and picks up the
// new column node (issue #248).
centerColRef.current = null
return
}
const rect = col.getBoundingClientRect()
// The bottom panel only cares about the horizontal edges: a pure height
// change (the bottom panel itself opening/closing) must not re-render,
@@ -429,15 +437,15 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// so its parent IS that column — no hashed-class or positional
// dependency (layout.css uses the same anchor). The shell swaps the
// boot page for the AppFrame only AFTER boot settles, so the first
// query may miss it. Never give up: watch #root's children (the swap
// mutates them) and re-run this locator — querying once and bailing
// would strand the panel at the zero-size fallback forever (observed:
// a 1px sliver at the viewport's left edge).
// query may miss it. Never give up: watch #root's subtree (the swap and
// HMR re-renders mutate it) and re-run this locator — querying once and
// bailing would strand the panel at the zero-size fallback forever
// (observed: a 1px sliver at the viewport's left edge).
const locate = (): void => {
if (disposed) return
const col = document.querySelector('#root [data-slot="conversation"]')
?.parentElement as HTMLElement | undefined
if (col === undefined) {
if (col === undefined || !col.isConnected) {
if (centerColRef.current !== null) {
centerColRef.current = null
observer?.disconnect()
@@ -446,17 +454,33 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
return
}
if (centerColRef.current !== col) {
// A NEW column node (boot swap, HMR re-render, or a previous locate
// that found nothing): attach the ResizeObserver to THIS node and
// measure it once. Same-node size changes are the ResizeObserver's
// job — no forced measurement here, because a forced
// getBoundingClientRect per mutation would reflow the shell at
// mutation cadence.
centerColRef.current = col
observer?.disconnect()
observer = new ResizeObserver(measureCenter)
observer.observe(col)
measureCenter()
}
measureCenter()
}
locate()
const watcher = new MutationObserver(locate)
// rAF-debounce the mutation watchers: #root's subtree changes at chat
// cadence (streaming turns), and locate() itself must stay cheap.
let locateFrame: number | null = null
const scheduleLocate = (): void => {
if (locateFrame !== null) return
locateFrame = requestAnimationFrame(() => {
locateFrame = null
locate()
})
}
const watcher = new MutationObserver(scheduleLocate)
const root = document.getElementById('root')
if (root !== null) watcher.observe(root, { childList: true })
if (root !== null) watcher.observe(root, { childList: true, subtree: true })
// The layout push writes --dsh-sidebar-* on <html>. A HMR re-activation
// clears those variables on teardown and re-writes them on setup — and
// that is also the moment the shell may have re-created the center
@@ -465,10 +489,20 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// Watching <html>'s style attribute catches that re-sync: the push
// rewrite re-locates and re-measures, so the bottom panel recovers
// instead of staying hidden on a stale {0,0} center rect.
const htmlStyleWatcher = new MutationObserver(locate)
const htmlStyleWatcher = new MutationObserver(scheduleLocate)
htmlStyleWatcher.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] })
// Last-resort safety net (issue #248): no watcher is guaranteed to fire
// for every HMR teardown/setup interleaving (e.g. the style attribute
// may end up byte-identical, and the col may be swapped before the
// subtree watcher attaches). A slow unconditional re-locate makes the
// panel converge on the real column within a couple of seconds no
// matter what sequence the shell used. locate() is cheap when nothing
// changed (one querySelector + an identity compare; no forced layout).
const retry = window.setInterval(locate, 1500)
return () => {
disposed = true
if (locateFrame !== null) cancelAnimationFrame(locateFrame)
window.clearInterval(retry)
observer?.disconnect()
watcher.disconnect()
htmlStyleWatcher.disconnect()
@@ -556,12 +590,20 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// (issue #106: skins that inset the panels must not fight JS coords).
}
/** Last size a drag actually applied to the DOM (updated by applyDrag).
* When a pointer stream dies without any position info (issue #247: an
* ultra-fast flick whose release events carried no usable coordinates),
* the abort path adopts this instead of rolling back to the pre-drag
* value — the DOM's current size is the only truthful record left. */
const lastDragSize = useRef<{ width: number; height: number } | null>(null)
/** Apply a drag size to the DOM without touching React state or the store.
* The bottom panel's right edge tracks the right panel's left edge HERE
* too — React state only updates on release, so the inline right must be
* written directly or the bottom panel would lag the sidebar mid-drag.
* The layout push rides the shared writer (writeGeometry). */
const applyDrag = (width: number, height: number): void => {
lastDragSize.current = { width, height }
panelRef.current?.style.setProperty('width', `${width}px`)
bottomRef.current?.style.setProperty('height', `${height}px`)
// centerRect.right is the center column's right edge at the committed
@@ -640,9 +682,18 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
* and an ultra-fast flick can cancel the stream before ANY move lands.
* The commit order is therefore: the LAST KNOWN dragged size (the rAF
* pending value) first, then the interrupting event's own pointer
* position (pointercancel / lostpointercapture still carry coordinates),
* and only a drag that produced neither (pure down+up at the same spot)
* reverts to the store's committed sizes.
* position (only pointercancel is trusted to carry coordinates —
* lostpointercapture's coordinates are not guaranteed, so the handlers
* pass the event only from pointercancel), and finally the size the drag
* last APPLIED to the DOM (lastDragSize). A drag that produced none of
* those (pure down+up at the same spot) commits the store's own sizes —
* a no-op, never an explicit rollback (issue #247: v0.13.1 never reverted
* an interrupted fast flick; the abort path added in the unified-host
* refactor did, and that regression is what this ordering removes).
*
* Every commit path marks the drag committed, so the interrupt
* double-fire (pointercancel → lostpointercapture) cannot commit once
* and then roll the same drag back.
*/
const abortDrag = (reset: () => void, event?: { clientX: number; clientY: number }): void => {
if (dragCommitted.current) return
@@ -653,8 +704,8 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
width = pending.width
height = pending.height
} else if (event !== undefined) {
// No move ever landed: the cancel/lost-capture position is all we
// have — commit it (clamped) instead of rolling back the flick.
// No move ever landed: the cancel position is all we have — commit it
// (clamped) instead of rolling back the flick.
if (draggingWidth) {
width = clampWidth(widthDrag.current.startWidth + (widthDrag.current.startX - event.clientX))
height = state?.bottomOpen === true ? Math.min(state.bottomHeight, window.innerHeight) : 0
@@ -667,6 +718,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
}
}
if (width !== undefined && height !== undefined) {
dragCommitted.current = true
pendingDrag.current = null
if (dragFrame.current !== null) {
cancelAnimationFrame(dragFrame.current)
@@ -677,11 +729,25 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
measureCenter()
store.reduce(s => setBottomHeight(setWidth(s, width), height))
} else {
// No pending write and no usable event coordinates: keep the size the
// drag last applied instead of rolling back to the pre-drag value
// (the flick's moves may have been consumed by the rAF just before
// the stream died — the DOM already shows the dragged size). Clamp to
// the current geometry like the layout-push effect (closed/narrow
// panels are written 0 by the push).
dragCommitted.current = true
stopDragScheduling()
applyDrag(
!narrow && state?.panelOpen === true ? Math.min(state?.width ?? 0, window.innerWidth) : 0,
!narrow && state?.bottomOpen === true ? Math.min(state.bottomHeight, window.innerHeight) : 0,
)
const last = lastDragSize.current
const adoptedWidth = !narrow && state?.panelOpen === true
? Math.min(last?.width ?? state?.width ?? 0, window.innerWidth)
: 0
const adoptedHeight = !narrow && state?.bottomOpen === true
? Math.min(last?.height ?? state?.bottomHeight ?? 0, window.innerHeight)
: 0
applyDrag(adoptedWidth, adoptedHeight)
draggingRef.current = false
measureCenter()
store.reduce(s => setBottomHeight(setWidth(s, adoptedWidth), adoptedHeight))
}
reset()
}
@@ -955,22 +1021,20 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
}}
onPointerUp={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
if (dragCommitted.current) return
dragCommitted.current = true
event.currentTarget.releasePointerCapture(event.pointerId)
const { startX, startWidth } = widthDrag.current
// A very fast release can merge/lose pointermove bursts:
// prefer the LAST KNOWN dragged width (already clamped by
// the move handler) over the up position, which may equal
// the down position when no move landed.
const pending = pendingDrag.current
const width = pending !== null
? pending.width
: clampWidth(startWidth + (startX - event.clientX))
// The up position is the pointer's FINAL position — a fast
// flick's tail is coalesced into the up event, so the last
// pointermove (the rAF pending value) can be stale. Commit
// from the up position (v0.13.1 semantics; issue #247).
const width = clampWidth(startWidth + (startX - event.clientX))
const height = state.bottomOpen ? Math.min(state.bottomHeight, window.innerHeight) : 0
commitDrag(width, height, s => setWidth(s, width))
setDraggingWidth(false)
}}
onPointerCancel={() => { abortDrag(() => setDraggingWidth(false)) }}
onPointerCancel={(event) => { abortDrag(() => setDraggingWidth(false), event) }}
onLostPointerCapture={() => { abortDrag(() => setDraggingWidth(false)) }}
/>
)}
@@ -1021,22 +1085,18 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
}}
onPointerUp={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
if (dragCommitted.current) return
dragCommitted.current = true
event.currentTarget.releasePointerCapture(event.pointerId)
const { startX, startY, startWidth, startHeight } = cornerDrag.current
// Last known dragged size wins over the up position (fast
// releases can merge/lose pointermove bursts).
const pending = pendingDrag.current
const width = pending !== null
? pending.width
: clampWidth(startWidth + (startX - event.clientX))
const height = pending !== null
? pending.height
: clampHeight(startHeight + (startY - event.clientY))
// Up position wins over the rAF pending value (see the width
// strip handler — issue #247).
const width = clampWidth(startWidth + (startX - event.clientX))
const height = clampHeight(startHeight + (startY - event.clientY))
commitDrag(width, height, s => setBottomHeight(setWidth(s, width), height))
setDraggingCorner(false)
}}
onPointerCancel={() => { abortDrag(() => setDraggingCorner(false)) }}
onPointerCancel={(event) => { abortDrag(() => setDraggingCorner(false), event) }}
onLostPointerCapture={() => { abortDrag(() => setDraggingCorner(false)) }}
/>
)}
@@ -1061,6 +1121,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
<div
ref={bottomRef}
className={clsx(css.bottomPanel, !state.bottomOpen && css.bottomPanelHidden)}
data-dsh-bottom-panel=""
style={{
height: Math.min(state.bottomHeight, window.innerHeight),
left: centerRect.left,
@@ -1102,19 +1163,17 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
}}
onPointerUp={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
if (dragCommitted.current) return
dragCommitted.current = true
event.currentTarget.releasePointerCapture(event.pointerId)
const { startY, startHeight } = bottomDrag.current
// Last known dragged height wins over the up position (fast
// releases can merge/lose pointermove bursts).
const pending = pendingDrag.current
const height = pending !== null
? pending.height
: clampHeight(startHeight + (startY - event.clientY))
// Up position wins over the rAF pending value (see the width
// strip handler — issue #247).
const height = clampHeight(startHeight + (startY - event.clientY))
commitDrag(Math.min(state.width, window.innerWidth), height, s => setBottomHeight(s, height))
setDraggingBottom(false)
}}
onPointerCancel={() => { abortDrag(() => setDraggingBottom(false)) }}
onPointerCancel={(event) => { abortDrag(() => setDraggingBottom(false), event) }}
onLostPointerCapture={() => { abortDrag(() => setDraggingBottom(false)) }}
/>
{/*
+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)
})