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:
+29
-9
@@ -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')
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user