refactor(client): 抽取中心列定位/测量链到 sidebar/use-center-column.ts
Sidebar.tsx 拆分第二块:centerRectRef/centerMeasured/centerColRef/ draggingRef、measureCenter(ref + 直写 DOM,避免动画帧级 re-render) 与整条 locate/ResizeObserver/MutationObserver/1.5s 重试链原样迁出到 src/client/sidebar/use-center-column.ts,注释逐字保留。bottomRef 与 state?.bottomOpen 由调用方传入(bottomRef 在主文件先建再共享,行为与 原先 measureCenter 闭包捕获一致)。 行为不变:hook 每次 render 全量重跑,effect 依赖与值完全相同;唯一 文字差异是三处依赖数组补列了跨 hook 边界后失去 useRef 溯源的稳定 ref 对象(centerColRef/draggingRef,标识恒定,不改变触发时机), 为 react-hooks/exhaustive-deps 零告警所需。
This commit is contained in:
1 parent
dbc73f0bf3
commit
6bccd21a49
2 files changed
+187
-160
No files matched your search
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Center-column tracking (extracted from Sidebar.tsx, behavior identical):
|
||||
* the bottom panel spans ONLY the app shell's center column ("squeezes the
|
||||
* agent output area") — it starts at the app sidebar's right edge and ends
|
||||
* at the details column's left edge (the details column sits between the
|
||||
* center and the right panel). Measured directly from the AppFrame's center
|
||||
* column DOM (the parent of the [data-slot="conversation"] wrapper —
|
||||
* layout.css's center column) so the bottom panel tracks the column's real
|
||||
* horizontal edges — including the animated AppFrame padding reservation
|
||||
* while the right panel opens/closes; a frame that never appears keeps the
|
||||
* initial zero-size fallback (the panel renders at 0 width until measured).
|
||||
* The rect lives in a REF (not state): the open/close transition resizes
|
||||
* the center column EVERY frame for its duration, and reacting per frame
|
||||
* with setState re-renders the whole Sidebar (every mounted tab) at
|
||||
* animation cadence — the visible toggle jank (#315). measureCenter
|
||||
* writes the bottom panel's edges directly (same DOM-write pattern as
|
||||
* applyDrag), so the panel still tracks the column per frame with zero
|
||||
* React work; `centerMeasured` flips ONCE to gate the hidden→visible
|
||||
* first-paint fallback.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { resolveCenterColumn } from '../center-column.ts'
|
||||
|
||||
export function useCenterColumn(
|
||||
/** The bottom panel element: measureCenter writes its edges directly. */
|
||||
bottomRef: { readonly current: HTMLDivElement | null },
|
||||
/** Re-runs the whole locate/measure chain on change (opening the bottom
|
||||
* panel re-runs the chain: a panel opened before the center column was
|
||||
* ever found must not stay invisible forever). */
|
||||
bottomOpen: boolean | undefined,
|
||||
) {
|
||||
const centerRectRef = useRef({ left: 0, right: 0 })
|
||||
const [centerMeasured, setCenterMeasured] = useState(false)
|
||||
// Refs keep the measure step stable across renders and let it skip work
|
||||
// mid-drag: during a width/corner drag the layout push resizes the center
|
||||
// column every frame, and reacting (setCenterRect → re-render) would
|
||||
// re-introduce the drag lag this shell deliberately avoids. applyDrag
|
||||
// writes the bottom panel's edges directly, so measurement pauses then.
|
||||
const centerColRef = useRef<HTMLElement | null>(null)
|
||||
const draggingRef = useRef(false)
|
||||
const measureCenter = useCallback((): void => {
|
||||
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()
|
||||
// Ref + direct DOM write (see the centerRectRef comment): the bottom
|
||||
// panel keeps tracking the center column per frame during the right
|
||||
// panel's open/close animation without re-rendering the shell. The
|
||||
// one-shot measured flip renders the panel visible once (a stale
|
||||
// {0,0} fallback would flash full-width).
|
||||
centerRectRef.current = { left: rect.left, right: rect.right }
|
||||
const bottom = bottomRef.current
|
||||
if (bottom !== null) {
|
||||
bottom.style.setProperty('left', `${rect.left}px`)
|
||||
bottom.style.setProperty('right', `${window.innerWidth - rect.right}px`)
|
||||
}
|
||||
setCenterMeasured(prev => (prev ? prev : true))
|
||||
}, [bottomRef])
|
||||
useEffect(() => {
|
||||
let disposed = false
|
||||
let observer: ResizeObserver | undefined
|
||||
// Locate the AppFrame's center column. DSH 0.1.x wraps slot hosts in
|
||||
// [data-slot] containers: the conversation slot wrapper
|
||||
// ([data-slot="conversation"]) sits directly inside the center column,
|
||||
// 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 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
|
||||
// Hot path (#403): streaming output mutates #root at token cadence.
|
||||
// Reuse a still-connected center column and only query after boot/HMR
|
||||
// detached the cached node.
|
||||
const col = resolveCenterColumn(centerColRef.current)
|
||||
if (col === undefined || !col.isConnected) {
|
||||
if (centerColRef.current !== null) {
|
||||
centerColRef.current.removeAttribute('data-dsh-center-col')
|
||||
centerColRef.current = null
|
||||
observer?.disconnect()
|
||||
observer = undefined
|
||||
}
|
||||
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. The tag retargets with the ref: layout.css's
|
||||
// bottom-push rule anchors on [data-dsh-center-col], so exactly the
|
||||
// measured node carries it (a stale tag on a swapped-out node would
|
||||
// leave the push rule anchorless or doubled).
|
||||
centerColRef.current?.removeAttribute('data-dsh-center-col')
|
||||
centerColRef.current = col
|
||||
col.setAttribute('data-dsh-center-col', '')
|
||||
observer?.disconnect()
|
||||
observer = new ResizeObserver(measureCenter)
|
||||
observer.observe(col)
|
||||
measureCenter()
|
||||
}
|
||||
}
|
||||
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
|
||||
// Mid-drag every frame writes --dsh-sidebar-* on <html>'s style
|
||||
// attribute, which is the mutation this watcher observes — relocating
|
||||
// per drag frame is pointless (the center column node cannot change
|
||||
// while the pointer is captured) and adds locator work to every
|
||||
// frame's budget (#315). The 1.5s retry below still covers any node
|
||||
// swap that somehow lands mid-drag.
|
||||
if (draggingRef.current) return
|
||||
locateFrame = requestAnimationFrame(() => {
|
||||
locateFrame = null
|
||||
locate()
|
||||
})
|
||||
}
|
||||
const watcher = new MutationObserver(scheduleLocate)
|
||||
const root = document.getElementById('root')
|
||||
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
|
||||
// column under a REUSED #root child (React swaps nodes in place, so
|
||||
// #root's childList never changes and the watcher above never fires).
|
||||
// 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(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 query-free while the
|
||||
// cached column stays connected; only a detached/missing cache falls
|
||||
// back to the document selector.
|
||||
const retry = window.setInterval(locate, 1500)
|
||||
return () => {
|
||||
disposed = true
|
||||
if (locateFrame !== null) cancelAnimationFrame(locateFrame)
|
||||
window.clearInterval(retry)
|
||||
observer?.disconnect()
|
||||
watcher.disconnect()
|
||||
htmlStyleWatcher.disconnect()
|
||||
centerColRef.current?.removeAttribute('data-dsh-center-col')
|
||||
centerColRef.current = null
|
||||
}
|
||||
// Opening the bottom panel re-runs the whole locate/measure chain: a
|
||||
// panel opened before the center column was ever found must not stay
|
||||
// invisible forever (the HMR recovery path depends on the observers
|
||||
// above, this is the belt-and-braces retry for the open moment itself).
|
||||
}, [measureCenter, bottomOpen])
|
||||
|
||||
return { centerColRef, centerRectRef, centerMeasured, measureCenter, draggingRef }
|
||||
}
|
||||
Reference in new issue
Block a user