fix(desktop): reconcile thread arrivals at bottom (#3585)

## Summary
- reconcile stale native-scroll anchors when a reply arrives at the
physical floor
- clear the thread new-message affordance instead of incrementing it
from stale cached state
- preserve the existing mid-history path and add direct lifecycle
regression coverage

## Why
PR #3411 fixed geometry-driven reconciliation, but the reply-arrival
branch still trusted a cached `message` anchor without checking the
rendered position. Native anchoring could return a short thread to the
floor without another scroll/resize callback, then the next reply
incremented the pill anyway.

## Verification
- Desktop checks passed
- Desktop typecheck passed
- focused lifecycle test passed (6/6)
- push hook full Desktop unit suite passed (3,770/3,770)
- `git diff --check` passed

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
This commit is contained in:
Wes
2026-07-29 12:46:47 -07:00
committed by GitHub
co-authored by Carl
parent 047533c56c
commit b42a8d447e
2 changed files with 108 additions and 1 deletions
@@ -247,13 +247,20 @@ function Harness({ channelId, onTargetSettled, refs }) {
return null;
}
function BottomStateHarness({ messages, onState, refs }) {
function BottomStateHarness({
messages,
onState,
refs,
targetMessageId = null,
}) {
const anchored = useAnchoredScroll({
channelId: "conversation",
contentRef: refs.content,
isLoading: false,
messages,
pinTargetCentered: targetMessageId !== null,
scrollContainerRef: refs.container,
targetMessageId,
});
onState(anchored);
return null;
@@ -329,6 +336,90 @@ test("channel change attaches pinned-center observers after refs mount", async (
});
});
test("arrival at the physical floor does not preserve a stale unread state", async () => {
const refs = {
container: { current: null },
content: { current: null },
};
const root = createRoot(document.createElement("div"));
const nodes = makePinnedCenterNodes();
refs.container.current = nodes.container;
refs.content.current = nodes.content;
let state = null;
const render = (messages) =>
root.render(
React.createElement(BottomStateHarness, {
messages,
onState: (nextState) => {
state = nextState;
},
refs,
}),
);
await act(async () => render([{ id: "first" }]));
await act(async () => new Promise((resolve) => setTimeout(resolve, 0)));
nodes.container.scrollTop = 100;
await act(async () => state.onScroll());
nodes.container.scrollTop = 100;
await act(async () => state.onScroll());
assert.equal(state.isAtBottom, false);
// Native anchoring can return the viewport to the floor without a scroll or
// resize callback, leaving only the hook's cached message anchor stale.
nodes.container.scrollTop =
nodes.container.scrollHeight - nodes.container.clientHeight;
await act(async () => render([{ id: "first" }, { id: "second" }]));
assert.equal(state.isAtBottom, true);
assert.equal(state.newMessageCount, 0);
await act(async () => root.unmount());
});
test("arrival does not steal an active layout target during floor-like reflow", async () => {
const refs = {
container: { current: null },
content: { current: null },
};
const root = createRoot(document.createElement("div"));
const nodes = makePinnedCenterNodes();
refs.container.current = nodes.container;
refs.content.current = nodes.content;
let state = null;
const render = (messages, targetMessageId = null) =>
root.render(
React.createElement(BottomStateHarness, {
messages,
onState: (nextState) => {
state = nextState;
},
refs,
targetMessageId,
}),
);
await act(async () => render([{ id: "selected" }]));
await act(async () => new Promise((resolve) => setTimeout(resolve, 0)));
nodes.container.scrollTop = 100;
await act(async () => state.onScroll());
nodes.container.scrollTop = 100;
await act(async () => state.onScroll());
assert.equal(state.isAtBottom, false);
// A focus/split presentation switch can commit fresh replies while the old
// container geometry momentarily reads as the physical floor. The explicit
// layout target must win so the reading row is restored after reflow.
nodes.container.scrollTop =
nodes.container.scrollHeight - nodes.container.clientHeight;
await act(async () =>
render([{ id: "selected" }, { id: "second" }], "selected"),
);
assert.equal(state.isAtBottom, false);
assert.equal(state.newMessageCount, 1);
await act(async () => root.unmount());
});
test("container resize clears a stale new-message state at the physical floor", async () => {
const refs = {
container: { current: null },
@@ -714,6 +714,22 @@ export function useAnchoredScroll({
container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
}
if (newLatestArrived) setNewMessageCount(0);
} else if (
messagesArrived > 0 &&
!targetMessageId &&
!virtualizerOwnsPrependAnchoring &&
isAtBottomNow(container)
) {
// A native scroll/layout callback may not have reconciled a stale
// message anchor before this append commits. If the rendered result is
// still physically at the floor (common in short threads), do not turn
// that stale anchor into a visible unread affordance. Active navigation
// targets own the viewport and must be preserved across presentation
// reflow even when the old geometry momentarily reads as the floor.
anchorRef.current = { kind: "at-bottom" };
container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
setIsAtBottom(true);
setNewMessageCount(0);
} else if (messagesArrived > 0 && !virtualizerOwnsPrependAnchoring) {
// Anchored mid-history. An older-history prepend grows the content above
// the reading row; the browser's native scroll anchoring does NOT correct