diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index 887e3a3..e7553fc 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -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 . 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 '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 }) {
{ 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)) }} /> {/* diff --git a/tests/e2e/drag-layout.e2e.ts b/tests/e2e/drag-layout.e2e.ts index 422593e..d44e3bf 100644 --- a/tests/e2e/drag-layout.e2e.ts +++ b/tests/e2e/drag-layout.e2e.ts @@ -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 { + 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 { + 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 { + 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('*')] + .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 => 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 => 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) +})