fix(desktop): show skeleton during channel switch (#1141)

Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
Tyler
2026-06-19 13:04:55 -04:00
committed by GitHub
co-authored by npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr Tyler Longwell
parent 959f25982b
commit d1c3574ba7
3 changed files with 80 additions and 3 deletions
@@ -4,6 +4,7 @@ import test from "node:test";
import {
BOTTOM_THRESHOLD_PX,
buildDayGroupBoundaries,
isDeferredTimelineSnapshotStale,
isNearBottomMetrics,
resolveDeepLinkTarget,
selectDeferredListRenderState,
@@ -414,6 +415,42 @@ test("timeline-body-surface: empty only when live and deferred rows are empty",
);
});
test("deferred-snapshot: stale when channel ids diverge during channel switch", () => {
assert.equal(
isDeferredTimelineSnapshotStale({
deferredSnapshot: { channelId: "chan-a" },
liveSnapshot: { channelId: "chan-b" },
}),
true,
);
});
test("deferred-snapshot: fresh when channel ids match", () => {
assert.equal(
isDeferredTimelineSnapshotStale({
deferredSnapshot: { channelId: "chan-a" },
liveSnapshot: { channelId: "chan-a" },
}),
false,
);
});
test("timeline-body-surface: stale deferred channel snapshot paints skeleton instead of old list", () => {
const isStale = isDeferredTimelineSnapshotStale({
deferredSnapshot: { channelId: "chan-a" },
liveSnapshot: { channelId: "chan-b" },
});
assert.equal(
selectTimelineBodySurface({
deferredCount: 4,
isLoading: isStale,
liveCount: 0,
}),
"skeleton",
);
});
test("timeline-intro-surface: skeleton suppresses intro while loading", () => {
assert.equal(
selectTimelineIntroSurface({
@@ -203,6 +203,20 @@ export function selectTimelineBodySurface({
return renderState;
}
export type TimelineSnapshotIdentity = {
channelId: string | null;
};
export function isDeferredTimelineSnapshotStale({
deferredSnapshot,
liveSnapshot,
}: {
deferredSnapshot: TimelineSnapshotIdentity;
liveSnapshot: TimelineSnapshotIdentity;
}): boolean {
return deferredSnapshot.channelId !== liveSnapshot.channelId;
}
export type TimelineIntroSurface =
| "direct-message-intro"
| "channel-intro"
@@ -2,6 +2,7 @@ import * as React from "react";
import { Hash } from "lucide-react";
import {
isDeferredTimelineSnapshotStale,
selectTimelineBodySurface,
selectTimelineIntroSurface,
} from "@/features/messages/lib/timelineSnapshot";
@@ -113,6 +114,16 @@ type DirectMessageIntroParticipant = {
pubkey: string;
};
type TimelineSnapshot = {
channelId: string | null;
messages: TimelineMessage[];
};
const EMPTY_TIMELINE_SNAPSHOT: TimelineSnapshot = {
channelId: null,
messages: EMPTY_MESSAGES,
};
const MessageTimelineBase = React.forwardRef<
MessageTimelineHandle,
MessageTimelineProps
@@ -174,15 +185,30 @@ const MessageTimelineBase = React.forwardRef<
// scroll/autoscroll/deep-link logic reads the DOM (`scrollIntoView`,
// ResizeObserver on the content), so it must stay consistent with what's
// actually painted. You can't scroll to a row that hasn't committed yet.
const deferredMessages = React.useDeferredValue(messages, EMPTY_MESSAGES);
const isRenderPending = deferredMessages !== messages;
// Channel id travels with the deferred message snapshot. Without that guard, a
// route change can paint the previous channel's deferred rows for a frame even
// though the sidebar/header already moved to the new channel.
const liveSnapshot = React.useMemo<TimelineSnapshot>(
() => ({ channelId: channelId ?? null, messages }),
[channelId, messages],
);
const deferredSnapshot = React.useDeferredValue(
liveSnapshot,
EMPTY_TIMELINE_SNAPSHOT,
);
const deferredMessages = deferredSnapshot.messages;
const isDeferredSnapshotStale = isDeferredTimelineSnapshotStale({
deferredSnapshot,
liveSnapshot,
});
const isRenderPending = deferredSnapshot !== liveSnapshot;
const scrollRestorationId = targetMessageId
? `message-timeline:${channelId ?? "none"}:target:${targetMessageId}`
: `message-timeline:${channelId ?? "none"}`;
const timelineBodySurface = selectTimelineBodySurface({
deferredCount: deferredMessages.length,
isLoading,
isLoading: isLoading || isDeferredSnapshotStale,
liveCount: messages.length,
});
const showTimelineSkeleton = timelineBodySurface === "skeleton";