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)) }}
/>
{/*