Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions desktop/src/features/messages/lib/timelineSnapshot.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import test from "node:test";
import {
BOTTOM_THRESHOLD_PX,
buildDayGroupBoundaries,
isDeferredTimelineSnapshotStale,
isNearBottomMetrics,
resolveDeepLinkTarget,
selectDeferredListRenderState,
Expand Down Expand Up @@ -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({
Expand Down
14 changes: 14 additions & 0 deletions desktop/src/features/messages/lib/timelineSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
32 changes: 29 additions & 3 deletions desktop/src/features/messages/ui/MessageTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as React from "react";
import { Hash } from "lucide-react";

import {
isDeferredTimelineSnapshotStale,
selectTimelineBodySurface,
selectTimelineIntroSurface,
} from "@/features/messages/lib/timelineSnapshot";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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";
Expand Down