fix(native-chat): let a pinned row reach the mounted window

Two faults the windowing tests turned up, plus the handles they needed.

The virtualizer memoizes its mounted index list on the range extractor's
identity. Holding that identity stable — which is right for the measurement
memo, and was the reason it was written that way — meant a row pinned after the
fact was never picked up: revealing a diff in a row the window had left behind
pointed at a row that stayed unmounted. The extractor now changes identity with
the pinned set, which is not a dependency of the measurement memo, so nothing
expensive is rebuilt.

The offset a row sits at is read off the `offsetParent` chain, with a rect-based
fallback for the case where there is none. Using that fallback for the window's
own scroll margin was wrong in kind: with no layout to measure, it returns the
scroll position itself, so the margin tracked the offset and the window sat at
the top of the transcript wherever the reader scrolled. The margin now takes the
offset chain or nothing; the fallback stays where it belongs, on the reveal.

The scroll root and the window's spacer are named, so measurement can find the
scroll root without depending on which utility class makes it scroll, and so a
test can tell a window from a whole transcript.
This commit is contained in:
Merge Sim
2026-09-10 01:52:19 -07:00
parent 8ba1521719
commit d81a086053
3 changed files with 33 additions and 14 deletions
@@ -237,6 +237,9 @@ export function NativeChatMessageList({
<div
ref={scrollRef}
onScroll={onScroll}
// Named so measurement can find the scroll root without depending on
// which utility class happens to make it scroll.
data-native-chat-scroll
className="scrollbar-sleek relative h-full overflow-y-auto [scrollbar-gutter:stable_both-edges]"
// Why: `zoom` scales the chat transcript's text and layout together,
// scoped to this pane so the rest of the app is untouched. It sits on
@@ -33,6 +33,9 @@ export function NativeChatTranscriptItems({
return (
<div
ref={window.sizerRef}
// Present only while windowing is engaged: the whole-transcript path has
// no spacer, so this is also how a test tells the two apart.
data-native-chat-window
className="relative w-full"
style={{ height: `${window.totalSize}px` }}
>
@@ -46,7 +46,10 @@ export type NativeChatTranscriptWindow = {
* is zoomed: rects are viewport pixels, `scrollTop` is not. Absolutely
* positioned windowed rows are placed with `top`, never a transform, so this
* stays true through the window as well. */
export function nativeChatScrollOffsetWithin(element: HTMLElement, container: HTMLElement): number {
export function nativeChatScrollOffsetWithin(
element: HTMLElement,
container: HTMLElement
): number | null {
let top = 0
let node: HTMLElement | null = element
while (node !== null && node !== container) {
@@ -57,12 +60,14 @@ export function nativeChatScrollOffsetWithin(element: HTMLElement, container: HT
const parent = node.offsetParent as HTMLElement | null | undefined
node = parent && typeof parent.offsetTop === 'number' ? parent : null
}
if (node === container) {
return top
}
// No chain to walk — a positioned ancestor outside the scroller, or a DOM with
// no layout at all. Rects still describe the distance, in viewport pixels; the
// container's own measured zoom converts them back into scroll pixels.
return node === container ? top : null
}
/** Same distance read off rects, for the case where there is no `offsetParent`
* chain to walk. Rects are viewport pixels, so the container's own measured
* zoom converts them back; a container with no layout reports no zoom and no
* distance, which leaves the offset where it already is. */
function rectOffsetWithin(element: HTMLElement, container: HTMLElement): number {
const containerRect = container.getBoundingClientRect()
const zoom =
container.offsetHeight > 0 && containerRect.height > 0
@@ -91,9 +96,6 @@ export function useNativeChatTranscriptWindow({
() => nativeChatPinnedRowIndexes({ count: slots.length, revealIndex }),
[slots.length, revealIndex]
)
const pinnedRef = useRef(pinned)
pinnedRef.current = pinned
// Stable identities: the virtualizer keys its measurement memo on these, so a
// fresh closure per render would rebuild every row's offset on every frame.
const estimateSize = useCallback(
@@ -104,10 +106,15 @@ export function useNativeChatTranscriptWindow({
(index: number) => slotsRef.current[index]?.message.id ?? index,
[]
)
// Identity tracks the pinned set on purpose. The virtualizer memoizes the
// mounted indexes on this function, so a stable one would keep serving the
// range from before a row was pinned — and a reveal would point at a row that
// never mounted. It is not a dependency of the measurement memo, so nothing
// expensive is rebuilt by changing it.
const rangeExtractor = useCallback(
(range: { startIndex: number; endIndex: number; overscan: number; count: number }) =>
nativeChatTranscriptRange(range, pinnedRef.current),
[]
nativeChatTranscriptRange(range, pinned),
[pinned]
)
const virtualizer = useVirtualizer({
@@ -133,8 +140,13 @@ export function useNativeChatTranscriptWindow({
if (!container || !sizer) {
return
}
// Only the offset chain, never the rect fallback: a container with no
// layout would report the scroll position itself as the margin, which would
// hold the window at the top of the transcript no matter where it scrolled.
const offset = nativeChatScrollOffsetWithin(sizer, container)
setScrollMargin((current) => (current === offset ? current : offset))
if (offset !== null) {
setScrollMargin((current) => (current === offset ? current : offset))
}
}, [scrollRef])
useLayoutEffect(readScrollMargin)
@@ -173,7 +185,8 @@ export function useNativeChatTranscriptWindow({
if (!container) {
return
}
const top = nativeChatScrollOffsetWithin(element, container)
const top =
nativeChatScrollOffsetWithin(element, container) ?? rectOffsetWithin(element, container)
// Through the virtualizer so a scroll it is still reconciling — the jump
// that mounted this row in the first place — is replaced rather than raced.
if (virtualizer.scrollElement) {