fix: 面板拖动布局闪烁 — 右侧栏关闭时底部高度拖动挤压 AppFrame + 释放瞬间整体铺满再回弹

- applyDrag 仅在右侧面板打开时写入 --dsh-sidebar-width(底部高度拖动传入的是右侧栏持久化宽度,关闭时会把整个 AppFrame 左挤,直到松手才恢复)
- 布局 push effect 拆分为「仅设置」+「仅卸载移除」:React 同一提交内先执行全部 cleanup 再执行全部 setup,原 cleanup 删除 CSS 变量后,draggingRef effect 的 measureCenter 强制重算把 0px 缓存为 margin-right 过渡起点,释放时过渡重新启用 → 0→W 动画(先铺满页面再回弹)
- 回归测试:size change 提交过程中不得 removeProperty 布局变量(仅卸载时移除,issue #31 语义保留)
This commit is contained in:
ChenRuoT
2026-08-17 23:24:29 +08:00
parent 0b5405117d
commit bb8df17ecb
2 changed files with 60 additions and 10 deletions
+29 -9
View File
@@ -472,7 +472,13 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// `width + detailsWidth` — derived from the measured column, keeping the
// drag write-only (no React re-render mid-drag).
bottomRef.current?.style.setProperty('right', `${(window.innerWidth - centerRect.right) + (width - (state?.width ?? 0))}px`)
document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`)
// The width push exists only while the right panel is OPEN (the layout
// push effect writes 0 while it is collapsed). The bottom-height drag
// reuses this function with the right panel's PERSISTED width as `width`,
// so writing it unconditionally would squeeze the whole app shell left
// mid-drag — as if the collapsed right panel were still occupying space —
// until the store commit on release snaps it back.
document.documentElement.style.setProperty('--dsh-sidebar-width', `${state?.panelOpen === true ? width : 0}px`)
document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}px`)
if (cornerRef.current !== null) {
cornerRef.current.style.left = `${window.innerWidth - width - 6}px`
@@ -516,6 +522,18 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// crush the app shell to zero. Dragging disables the layout transition.
// On NARROW viewports the drawer FLOATS over the app shell — no push, the
// conversation keeps the full width behind the drawer.
//
// This effect is deliberately SET-only (no cleanup): React runs every
// effect cleanup before every effect setup in the same commit, so a
// cleanup here would remove both variables before the draggingRef effect
// (declared above) runs its setup and forces a layout in measureCenter
// (getBoundingClientRect). That forced recalc would resolve #root's
// margin-right to the 0px fallback, cache it as the transition start
// value, and measure centerRect far too wide; once the body attribute
// effect re-enables transitions on pointer-up, the shell would animate
// margin-right 0 → width — the visible "expand to the full page then
// bounce back" flash. Unmount release lives in the effect below
// (issue #31).
useEffect(() => {
const width = !narrow && snapshot.state?.panelOpen === true
? Math.min(snapshot.state.width, window.innerWidth)
@@ -525,18 +543,20 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
: 0
document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`)
document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}px`)
// Unmount must release the push (issue #31): when the boundary swaps the
// whole sidebar after a render crash (or the plugin fiber is disposed /
// HMR), the CSS variables would otherwise stay on <html> and layout.css
// keeps squeezing #root with a stale margin — "the sidebar cannot be
// hidden" until a full reload. removeProperty restores the CSS fallback
// (var(--dsh-sidebar-width, 0px)); React re-runs cleanup+setup in the
// same commit on state changes, so there is no visible flicker.
}, [narrow, snapshot.state?.panelOpen, snapshot.state?.width, snapshot.state?.bottomOpen, snapshot.state?.bottomHeight])
// Unmount must release the push (issue #31): when the boundary swaps the
// whole sidebar after a render crash (or the plugin fiber is disposed /
// HMR), the CSS variables would otherwise stay on <html> and layout.css
// keeps squeezing #root with a stale margin — "the sidebar cannot be
// hidden" until a full reload. removeProperty restores the CSS fallback
// (var(--dsh-sidebar-width, 0px)). Kept separate from the set effect so a
// dep-change re-run never removes the variables mid-commit.
useEffect(() => {
return () => {
document.documentElement.style.removeProperty('--dsh-sidebar-width')
document.documentElement.style.removeProperty('--dsh-sidebar-height')
}
}, [narrow, snapshot.state?.panelOpen, snapshot.state?.width, snapshot.state?.bottomOpen, snapshot.state?.bottomHeight])
}, [])
useEffect(() => {
if (anyDragging) document.body.setAttribute('data-dsh-sidebar-dragging', '')
else document.body.removeAttribute('data-dsh-sidebar-dragging')
+31 -1
View File
@@ -26,7 +26,7 @@ import { act } from 'react-dom/test-utils'
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
import { Sidebar } from '../src/client/Sidebar.tsx'
import { createSidebarStore, type SidebarStore } from '../src/client/state.ts'
import { createSidebarStore, setBottomHeight, toggleBottomPanel, type SidebarStore } from '../src/client/state.ts'
import { createBetterSidebarService, type BetterSidebarService } from '../src/client/service.ts'
import { t } from '../src/client/locales.ts'
@@ -99,6 +99,36 @@ describe('layout-push variable cleanup', () => {
expect(htmlStyle.getPropertyValue('--dsh-sidebar-width')).toBe('')
expect(htmlStyle.getPropertyValue('--dsh-sidebar-height')).toBe('')
})
it('a size change (release commit) re-applies the variables without removing them mid-commit', () => {
const { store, unmount } = mountSidebar()
const htmlStyle = document.documentElement.style
const width = store.getSnapshot().state!.width
const removeSpy = vi.spyOn(htmlStyle, 'removeProperty')
// Simulate a drag release: the bottom panel opens and its height commits.
act(() => { store.reduce(toggleBottomPanel) })
act(() => { store.reduce(s => setBottomHeight(s, 300)) })
expect(htmlStyle.getPropertyValue('--dsh-sidebar-width')).toBe(`${width}px`)
expect(htmlStyle.getPropertyValue('--dsh-sidebar-height')).toBe('300px')
// The commit must NOT have removed the variables at any point. React
// runs every effect cleanup before every effect setup in a commit, so a
// cleanup here would remove the variables before the draggingRef effect
// (declared above the layout push) forces a layout in measureCenter
// (getBoundingClientRect). That forced recalc resolves #root's
// margin-right to the 0px fallback, caches it as the transition start
// value, and measures centerRect too wide; once transitions re-enable on
// release, the shell animates margin-right 0 → width — the "expand to
// the full page then bounce back" flash.
const removalProps = removeSpy.mock.calls
.map(call => call[0] as string)
.filter(prop => prop === '--dsh-sidebar-width' || prop === '--dsh-sidebar-height')
expect(removalProps).toHaveLength(0)
// Only unmounting may remove them (issue #31).
unmount()
expect(removeSpy.mock.calls.some(call =>
call[0] === '--dsh-sidebar-width' || call[0] === '--dsh-sidebar-height')).toBe(true)
removeSpy.mockRestore()
})
})
describe('tab crash containment', () => {