mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user