fix(chat): re-pin to the measured content end after a resize, not the list's content length
Opening or closing the context panel re-wrapped the timeline; the settle handler then scrolled to LegendList's total content length, which still carried pre-wrap sizes for unmounted rows, leaving a viewport-sized blank tail under the last message.
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
getAnchoredTurnMetrics,
|
||||
getRowBottom,
|
||||
resolveChatListAnchoredEndSpace,
|
||||
resolveRealContentEndOffset,
|
||||
resolveTimelineIsAtEnd,
|
||||
type TimelineListMeasurementState,
|
||||
} from './timelineScrollAnchoring';
|
||||
@@ -183,6 +184,58 @@ describe('getAnchoredTurnMetrics', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveRealContentEndOffset', () => {
|
||||
test('puts the last row bottom just above the composer overlay', () => {
|
||||
const state = buildState({
|
||||
positions: [0, 1000],
|
||||
sizes: [1000, 200],
|
||||
scroll: 0,
|
||||
scrollLength: 700,
|
||||
});
|
||||
|
||||
expect(resolveRealContentEndOffset({ state, composerOverlayHeight: 180 })).toBe(680);
|
||||
});
|
||||
|
||||
test('ignores content length inflated by reserved end space or stale sizes', () => {
|
||||
// The list still reports a far larger content length than the measured
|
||||
// rows; the end offset must follow the rows, not that length.
|
||||
const state = buildState({
|
||||
positions: [0, 300],
|
||||
sizes: [300, 100],
|
||||
scroll: 900,
|
||||
scrollLength: 700,
|
||||
});
|
||||
|
||||
expect(resolveRealContentEndOffset({ state, composerOverlayHeight: 180 })).toBe(0);
|
||||
});
|
||||
|
||||
test('reserves extra slack below the content when asked', () => {
|
||||
const state = buildState({
|
||||
positions: [0, 1000],
|
||||
sizes: [1000, 200],
|
||||
scrollLength: 700,
|
||||
});
|
||||
|
||||
expect(resolveRealContentEndOffset({
|
||||
state,
|
||||
composerOverlayHeight: 180,
|
||||
extraInset: CHAT_LIST_ANCHOR_OFFSET,
|
||||
})).toBe(696);
|
||||
});
|
||||
|
||||
test('returns null for an empty timeline and for unmeasured last rows', () => {
|
||||
expect(resolveRealContentEndOffset({
|
||||
state: buildState({ positions: [], sizes: [] }),
|
||||
composerOverlayHeight: 180,
|
||||
})).toBeNull();
|
||||
|
||||
expect(resolveRealContentEndOffset({
|
||||
state: buildState({ positions: [0, 100], sizes: [100] }),
|
||||
composerOverlayHeight: 180,
|
||||
})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTimelineIsAtEnd', () => {
|
||||
test('uses a tight distance band against the full content length', () => {
|
||||
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1400, scrollLength: 600 })).toBe(true);
|
||||
|
||||
@@ -108,6 +108,30 @@ export const getAnchoredTurnMetrics = ({
|
||||
};
|
||||
};
|
||||
|
||||
// The scroll offset that puts the LAST REAL ROW's bottom just above the
|
||||
// composer overlay. Distinct from the list's own end offset, which is derived
|
||||
// from the total content length: that length includes any reserved anchored
|
||||
// end space and, right after rows re-wrap on a width change, row sizes that
|
||||
// have not been re-measured yet. Scrolling to it then lands below the real
|
||||
// content and leaves a blank tail. `extraInset` reserves additional slack
|
||||
// below the content when a caller wants the row to sit clear of the edge.
|
||||
export const resolveRealContentEndOffset = ({
|
||||
state,
|
||||
composerOverlayHeight,
|
||||
extraInset = 0,
|
||||
}: {
|
||||
readonly state: TimelineListMeasurementState;
|
||||
readonly composerOverlayHeight: number;
|
||||
readonly extraInset?: number;
|
||||
}): number | null => {
|
||||
const lastIndex = state.data.length - 1;
|
||||
if (lastIndex < 0) return null;
|
||||
const lastBottom = getRowBottom(state, lastIndex);
|
||||
if (lastBottom === null) return null;
|
||||
const visibleLength = Math.max(0, state.scrollLength - composerOverlayHeight - extraInset);
|
||||
return Math.max(0, lastBottom - visibleLength);
|
||||
};
|
||||
|
||||
// "At the end" for follow purposes is a tight band, not the list's isNearEnd
|
||||
// (half a viewport): that band hid the scroll-to-bottom pill and re-armed
|
||||
// follow while the user had genuinely scrolled away, yanking them back on the
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
CHAT_LIST_ANCHOR_OFFSET,
|
||||
getAnchoredTurnMetrics,
|
||||
getRowBottom,
|
||||
resolveRealContentEndOffset,
|
||||
resolveTimelineIsAtEnd,
|
||||
TIMELINE_FOLLOW_REARM_THRESHOLD_PX,
|
||||
type TimelineListMeasurementState,
|
||||
@@ -588,7 +589,25 @@ export const useChatTimelineScroll = ({
|
||||
quietTimer = null;
|
||||
widthResizingRef.current = false;
|
||||
if (isAtEndRef.current && pendingAnchorRef.current === null) {
|
||||
void listRef.current?.scrollToEnd({ animated: false });
|
||||
// Not scrollToEnd: the list's end offset comes from the
|
||||
// total content length, which still carries pre-wrap row
|
||||
// sizes (and any reserved anchored end space) right after a
|
||||
// width change. Landing there parks the last row near the
|
||||
// top of the viewport with a blank tail below it. Target
|
||||
// the measured bottom of the last real row instead.
|
||||
const list = listRef.current;
|
||||
const state = list?.getState();
|
||||
const offset = state
|
||||
? resolveRealContentEndOffset({
|
||||
state,
|
||||
composerOverlayHeight: composerOverlayHeightRef.current,
|
||||
})
|
||||
: null;
|
||||
if (list && offset !== null) {
|
||||
void list.scrollToOffset({ offset, animated: false });
|
||||
} else {
|
||||
void list?.scrollToEnd({ animated: false });
|
||||
}
|
||||
}
|
||||
}, 350);
|
||||
});
|
||||
@@ -637,15 +656,15 @@ export const useChatTimelineScroll = ({
|
||||
const lastIndex = state.data.length - 1;
|
||||
const lastBottom = lastIndex >= 0 ? getRowBottom(state, lastIndex) : null;
|
||||
if (lastBottom !== null && state.scroll > lastBottom) {
|
||||
const visibleLength = Math.max(
|
||||
0,
|
||||
state.scrollLength - composerOverlayHeightRef.current - CHAT_LIST_ANCHOR_OFFSET,
|
||||
);
|
||||
void list.scrollToOffset({
|
||||
offset: Math.max(0, lastBottom - visibleLength),
|
||||
animated: false,
|
||||
const offset = resolveRealContentEndOffset({
|
||||
state,
|
||||
composerOverlayHeight: composerOverlayHeightRef.current,
|
||||
extraInset: CHAT_LIST_ANCHOR_OFFSET,
|
||||
});
|
||||
return;
|
||||
if (offset !== null) {
|
||||
void list.scrollToOffset({ offset, animated: false });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user