+15
-8
@@ -767,18 +767,25 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
|||||||
? Math.min(snapshot.state.bottomHeight, window.innerHeight)
|
? Math.min(snapshot.state.bottomHeight, window.innerHeight)
|
||||||
: 0
|
: 0
|
||||||
writeGeometry(width, height)
|
writeGeometry(width, height)
|
||||||
// Unmount must release the push (issue #31): when the boundary swaps the
|
}, [narrow, snapshot.state?.panelOpen, snapshot.state?.width, snapshot.state?.bottomOpen, snapshot.state?.bottomHeight])
|
||||||
// whole sidebar after a render crash (or the plugin fiber is disposed /
|
// Unmount must release the push (issue #31): when the boundary swaps the
|
||||||
// HMR), the CSS variables would otherwise stay on <html> and layout.css
|
// whole sidebar after a render crash (or the plugin fiber is disposed /
|
||||||
// keeps squeezing #root with a stale margin — "the sidebar cannot be
|
// HMR), the CSS variables would otherwise stay on <html> and layout.css
|
||||||
// hidden" until a full reload. removeProperty restores the CSS fallback
|
// keeps squeezing #root with a stale margin — "the sidebar cannot be
|
||||||
// (var(--dsh-sidebar-width, 0px)); React re-runs cleanup+setup in the
|
// hidden" until a full reload. This lives in an UNMOUNT-ONLY effect, NOT
|
||||||
// same commit on state changes, so there is no visible flicker.
|
// in the push effect's cleanup: React can yield between a passive
|
||||||
|
// effect's cleanup and setup phases, and removing the variables on a
|
||||||
|
// dependency change used to paint the push-less layout for a frame (the
|
||||||
|
// center column went full width) while the re-add restarted the margin
|
||||||
|
// transition — the bottom panel flashed full width after every width
|
||||||
|
// drag (issue #258). Keeping the variables continuously valid while
|
||||||
|
// mounted makes the push invisible to mid-flush style recals.
|
||||||
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
document.documentElement.style.removeProperty('--dsh-sidebar-width')
|
document.documentElement.style.removeProperty('--dsh-sidebar-width')
|
||||||
document.documentElement.style.removeProperty('--dsh-sidebar-height')
|
document.documentElement.style.removeProperty('--dsh-sidebar-height')
|
||||||
}
|
}
|
||||||
}, [narrow, snapshot.state?.panelOpen, snapshot.state?.width, snapshot.state?.bottomOpen, snapshot.state?.bottomHeight])
|
}, [])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (anyDragging) document.body.setAttribute('data-dsh-sidebar-dragging', '')
|
if (anyDragging) document.body.setAttribute('data-dsh-sidebar-dragging', '')
|
||||||
else document.body.removeAttribute('data-dsh-sidebar-dragging')
|
else document.body.removeAttribute('data-dsh-sidebar-dragging')
|
||||||
|
|||||||
@@ -611,3 +611,94 @@ test('bottom panel re-locates a center column replaced deep inside #root (issue
|
|||||||
}, { timeout: 15_000 })
|
}, { timeout: 15_000 })
|
||||||
.toBe(true)
|
.toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/* ── issue #258: the bottom panel must not flash full-width after a width
|
||||||
|
drag release ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
test('bottom panel never flashes full-width after a width drag release (issue #258)', 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)
|
||||||
|
|
||||||
|
// The bottom panel must be OPEN too — its right edge is what tracks the
|
||||||
|
// center column (and what flashes full-width on release).
|
||||||
|
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 { startX, startY } = await settleWidthStrip(page)
|
||||||
|
|
||||||
|
// Per-frame sampler: the bottom panel's right edge vs the center column's
|
||||||
|
// right edge (where the layout push lands). The release path used to
|
||||||
|
// TRANSIENTLY REMOVE the push variables between the layout effect's
|
||||||
|
// cleanup and setup phases — React can yield a render frame in that gap,
|
||||||
|
// so the browser painted the push-less layout (center column full width),
|
||||||
|
// the drag-end measure cached that full-width rect, and the bottom panel
|
||||||
|
// rendered full-width while #root's margin transition animated back to
|
||||||
|
// the new width. Every post-release frame must keep the two edges glued
|
||||||
|
// at the committed width: no full-width frame, no drift, no margin
|
||||||
|
// animation.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const samples: Array<{ t: number; bottomRight: number; colRight: number; varW: string; dragging: boolean; iw: number }> = []
|
||||||
|
const loop = (): void => {
|
||||||
|
const bottom = document.querySelector('[data-dsh-better-sidebar] [data-dsh-bottom-panel]')
|
||||||
|
const col = document.querySelector('#root [data-slot="conversation"]')?.parentElement
|
||||||
|
samples.push({
|
||||||
|
t: performance.now(),
|
||||||
|
bottomRight: bottom?.getBoundingClientRect().right ?? -1,
|
||||||
|
colRight: col?.getBoundingClientRect().right ?? -1,
|
||||||
|
varW: document.documentElement.style.getPropertyValue('--dsh-sidebar-width'),
|
||||||
|
dragging: document.body.hasAttribute('data-dsh-sidebar-dragging'),
|
||||||
|
iw: window.innerWidth,
|
||||||
|
})
|
||||||
|
requestAnimationFrame(loop)
|
||||||
|
}
|
||||||
|
requestAnimationFrame(loop)
|
||||||
|
;(window as unknown as { __flashSamples: typeof samples }).__flashSamples = samples
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.mouse.move(startX, startY)
|
||||||
|
await page.mouse.down()
|
||||||
|
for (let i = 1; i <= 10; i++) {
|
||||||
|
await page.mouse.move(startX - i * 12, startY, { steps: 2 })
|
||||||
|
await page.waitForTimeout(30)
|
||||||
|
}
|
||||||
|
await page.mouse.up()
|
||||||
|
await page.waitForTimeout(500)
|
||||||
|
|
||||||
|
const samples = await page.evaluate(
|
||||||
|
() => (window as unknown as { __flashSamples: Array<{ t: number; bottomRight: number; colRight: number; varW: string; dragging: boolean; iw: number }> }).__flashSamples,
|
||||||
|
)
|
||||||
|
expect(samples.length, 'the frame sampler must have collected frames').toBeGreaterThan(20)
|
||||||
|
const lastDrag = [...samples].reverse().find(s => s.dragging)
|
||||||
|
expect(lastDrag, 'the dragging attribute must appear during the drag').toBeDefined()
|
||||||
|
const releaseIdx = samples.indexOf(lastDrag!) + 1
|
||||||
|
const release = samples[releaseIdx]!
|
||||||
|
expect(release.colRight, 'the center column must be measurable after release').toBeGreaterThan(0)
|
||||||
|
expect(release.bottomRight, 'the bottom panel must be measurable after release').toBeGreaterThan(0)
|
||||||
|
const settled = samples[samples.length - 1]!
|
||||||
|
for (const s of samples.slice(releaseIdx)) {
|
||||||
|
expect(
|
||||||
|
Math.abs(s.bottomRight - s.colRight),
|
||||||
|
`bottom panel drifted from the center column at t=${Math.round(s.t)} (bottom ${s.bottomRight} vs column ${s.colRight})`,
|
||||||
|
).toBeLessThanOrEqual(8)
|
||||||
|
const fullWidth = s.bottomRight >= s.iw - 1
|
||||||
|
expect(
|
||||||
|
fullWidth && s.varW !== '' && s.varW !== '0px',
|
||||||
|
`bottom panel flashed full-width at t=${Math.round(s.t)} (right ${s.bottomRight}, push ${s.varW})`,
|
||||||
|
).toBe(false)
|
||||||
|
expect(
|
||||||
|
Math.abs(s.colRight - settled.colRight),
|
||||||
|
`center column edge animated after release at t=${Math.round(s.t)} (${s.colRight} vs settled ${settled.colRight})`,
|
||||||
|
).toBeLessThanOrEqual(8)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user