Skip to content
Closed
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
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export default defineConfig({
"**/add-community-screenshots.spec.ts",
"**/hosted-communities-settings-screenshots.spec.ts",
"**/invites-settings-screenshots.spec.ts",
"**/message-performance.spec.ts",
"**/messaging.spec.ts",
"**/message-feedback-snapshots.spec.ts",
"**/custom-emoji.spec.ts",
Expand Down
15 changes: 14 additions & 1 deletion desktop/src/features/channels/ui/ChannelScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import * as React from "react";
import { useAppShell } from "@/app/AppShellContext";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import {
useChannelSelectionPerformanceMark,
useMeasuredOpenThread,
} from "@/features/messages/useMessagePerformance";
import { useActiveChannelHeader } from "@/features/channels/useActiveChannelHeader";
import { useChannelPaneHandlers } from "@/features/channels/useChannelPaneHandlers";
import { useMessageEventProfilePubkeys } from "@/features/channels/useMessageEventProfilePubkeys";
Expand Down Expand Up @@ -175,6 +179,7 @@ export function ChannelScreen({
? isNotifiedForThread(effectiveOpenThreadHeadId)
: false;
const previousActiveChannelIdRef = React.useRef(activeChannelId);
useChannelSelectionPerformanceMark(activeChannelId);
React.useEffect(() => {
const didChangeChannel =
previousActiveChannelIdRef.current !== activeChannelId;
Expand Down Expand Up @@ -595,6 +600,9 @@ export function ChannelScreen({
const settledChannelIdRef = React.useRef<string | null>(null);
const hasSettledThisChannel =
activeChannelId !== null && settledChannelIdRef.current === activeChannelId;
const hasAuthoritativeTimelineCache = Boolean(
windowQuery.data && windowQuery.data.pages.length > 0,
);
const timelineLoadingNow =
activeChannel !== null &&
activeChannel.channelType !== "forum" &&
Expand All @@ -606,6 +614,7 @@ export function ChannelScreen({
dataLength: messagesQuery.data?.length ?? null,
},
hasSettledThisChannel,
hasAuthoritativeTimelineCache,
);
const { settledChannelId, isLoading: isTimelineLoading } =
resolveTimelineLoadingLatch(
Expand Down Expand Up @@ -672,6 +681,10 @@ export function ChannelScreen({
profilePanelPubkey ||
channelManagementOpen,
);
const handleMeasuredOpenThread = useMeasuredOpenThread(
effectiveOpenThreadHeadId,
handleOpenThreadAndCloseAgentSession,
);
const displayedThreadHeadMessage = threadPanelData.threadHead;
const displayedThreadAllMessages = threadPanelData.messages;
const displayedThreadMessages = threadPanelData.visibleReplies;
Expand Down Expand Up @@ -914,7 +927,7 @@ export function ChannelScreen({
onOpenProfilePanel={handleOpenProfilePanel}
onResetThreadPanelWidth={handleThreadPanelWidthReset}
onCloseProfilePanel={handleCloseProfilePanel}
onOpenThread={handleOpenThreadAndCloseAgentSession}
onOpenThread={handleMeasuredOpenThread}
onSelectThreadReplyTarget={handleSelectThreadReplyTarget}
onSendMessage={handleSendMessage}
onSendToChannel={handleSendToChannel}
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/features/messages/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,8 @@ export function useChannelWindowQuery(channel: Channel | null) {
queryClient.getQueryData<ChannelWindowStore>(queryKey) ??
emptyChannelWindowStore(),
staleTime: Number.POSITIVE_INFINITY,
// Retain authoritative window metadata as long as the adjacent messages.
gcTime: 60 * 60 * 1_000,
});
}

Expand Down
31 changes: 31 additions & 0 deletions desktop/src/features/messages/lib/messagePerformance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
export const CHANNEL_SWITCH_START_MARK = "buzz:channel-switch:start";
export const CHANNEL_ROWS_PAINTED_MARK = "buzz:channel-switch:rows-painted";
export const CHANNEL_SWITCH_MEASURE =
"buzz:channel-switch:rows-painted-duration";

export const THREAD_OPEN_START_MARK = "buzz:thread-open:start";
export const THREAD_REPLIES_PAINTED_MARK = "buzz:thread-open:replies-painted";
export const THREAD_OPEN_MEASURE = "buzz:thread-open:replies-painted-duration";

export function startPerformanceMark(name: string): void {
if (typeof performance === "undefined") return;
performance.clearMarks(name);
performance.mark(name);
}

export function finishPerformanceMeasure(input: {
startMark: string;
endMark: string;
measure: string;
}): void {
if (
typeof performance === "undefined" ||
performance.getEntriesByName(input.startMark, "mark").length === 0
) {
return;
}
performance.clearMarks(input.endMark);
performance.mark(input.endMark);
performance.clearMeasures(input.measure);
performance.measure(input.measure, input.startMark, input.endMark);
}
22 changes: 22 additions & 0 deletions desktop/src/features/messages/lib/timelineLoadingState.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,28 @@ test("initial load holds the skeleton while the cold-load top-up fetches", () =>
);
});

test("pre-settle authoritative cache paints while a stale query refetches", () => {
assert.equal(
selectTimelineLoadingState(
{ ...settled, isFetching: true, dataLength: 8 },
false,
true,
),
false,
);
});

test("pre-settle live-only rows still hold the cold skeleton", () => {
assert.equal(
selectTimelineLoadingState(
{ ...settled, isFetching: true, dataLength: 8 },
false,
false,
),
true,
);
});

test("pre-settle placeholder rows paint immediately (snapshot revisit)", () => {
// A revisit painting from the React-Query cache or a persisted snapshot:
// placeholder rows are a previously-settled timeline, not a partial cold
Expand Down
12 changes: 9 additions & 3 deletions desktop/src/features/messages/lib/timelineLoadingState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,20 @@ export type TimelineQueryStatus = {
export function selectTimelineLoadingState(
status: TimelineQueryStatus,
hasSettled = true,
hasAuthoritativeCache = false,
): boolean {
if (status.isPending) {
return true;
}
if (!hasSettled) {
// Placeholder rows are a previously-settled timeline (React-Query cache on
// revisit, or a persisted snapshot) — paint them stale-then-revalidate
// instead of holding a skeleton over known content.
// A populated authoritative window proves these rows came from a previous
// settled load, not from the live subscription's partial pre-settle seed.
// Paint them while a stale query revalidates in the background.
if (hasAuthoritativeCache && (status.dataLength ?? 0) > 0) {
return false;
}
// Placeholder rows are also a previously-settled timeline (for callers
// that explicitly configure placeholderData).
if (status.isPlaceholderData && (status.dataLength ?? 0) > 0) {
return false;
}
Expand Down
5 changes: 5 additions & 0 deletions desktop/src/features/messages/ui/MessageThreadPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import { useComposerHeightPadding } from "./useComposerHeightPadding";
import { useStableSendToChannel } from "./useStableSendToChannel";
import { useAnchoredScroll } from "./useAnchoredScroll";
import { selectDeferredListRenderState } from "@/features/messages/lib/timelineSnapshot";
import { useThreadRepliesPaintedPerformanceMeasure } from "@/features/messages/useMessagePerformance";

type MessageThreadPanelProps = ThreadPanelLayoutProps & {
channel: Channel | null;
Expand Down Expand Up @@ -325,6 +326,10 @@ export function MessageThreadPanel({
EMPTY_THREAD_REPLIES,
);
const isRepliesPending = deferredThreadReplies !== threadReplies;
useThreadRepliesPaintedPerformanceMeasure(
threadHeadId,
deferredThreadReplies.length,
);
const scrollTargetIsVisibleReply = React.useMemo(
() =>
scrollTargetId !== null &&
Expand Down
12 changes: 11 additions & 1 deletion desktop/src/features/messages/ui/MessageTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from "@/features/messages/lib/timelineSnapshot";
import { preloadTimelineImages } from "@/features/messages/lib/timelineImagePreload";
import type { TimelineMessage } from "@/features/messages/types";
import { useChannelRowsPaintedPerformanceMeasure } from "@/features/messages/useMessagePerformance";
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
import type { ChannelWindowThreadSummary } from "@/features/messages/lib/channelWindowStore";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
Expand Down Expand Up @@ -255,6 +256,10 @@ const MessageTimelineBase = React.forwardRef<
EMPTY_TIMELINE_SNAPSHOT,
);
const deferredMessages = deferredSnapshot.messages;
useChannelRowsPaintedPerformanceMeasure(
channelId ?? null,
deferredMessages.length,
);
const imagePreloadStateRef = React.useRef({
activeImages: new Set<HTMLImageElement>(),
requestedUrls: new Set<string>(),
Expand Down Expand Up @@ -792,7 +797,12 @@ const MessageTimelineBase = React.forwardRef<
)}
>
{showTimelineSkeleton ? (
<TimelineSkeleton rows={timelineSkeletonRows} />
<div
className="contents"
data-testid="message-timeline-loading"
>
<TimelineSkeleton rows={timelineSkeletonRows} />
</div>
) : null}
{activeDirectMessageIntro ? (
<div
Expand Down
98 changes: 98 additions & 0 deletions desktop/src/features/messages/useMessagePerformance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import * as React from "react";

import {
CHANNEL_ROWS_PAINTED_MARK,
CHANNEL_SWITCH_MEASURE,
CHANNEL_SWITCH_START_MARK,
THREAD_OPEN_MEASURE,
THREAD_OPEN_START_MARK,
THREAD_REPLIES_PAINTED_MARK,
finishPerformanceMeasure,
startPerformanceMark,
} from "@/features/messages/lib/messagePerformance";
import type { TimelineMessage } from "@/features/messages/types";

export function useChannelSelectionPerformanceMark(
activeChannelId: string | null,
): void {
React.useEffect(() => {
const markChannelSelection = (event: MouseEvent) => {
const target = event.target;
if (!(target instanceof Element)) return;
const channelId = target
.closest<HTMLElement>("[data-channel-id]")
?.getAttribute("data-channel-id");
if (channelId && channelId !== activeChannelId) {
startPerformanceMark(CHANNEL_SWITCH_START_MARK);
}
};
document.addEventListener("click", markChannelSelection, true);
return () =>
document.removeEventListener("click", markChannelSelection, true);
}, [activeChannelId]);
}

export function useMeasuredOpenThread(
openThreadHeadId: string | null,
onOpenThread: (message: TimelineMessage) => void,
): (message: TimelineMessage) => void {
return React.useCallback(
(message: TimelineMessage) => {
if (openThreadHeadId !== message.id) {
startPerformanceMark(THREAD_OPEN_START_MARK);
}
onOpenThread(message);
},
[onOpenThread, openThreadHeadId],
);
}

function useRowsPaintedPerformanceMeasure(
identity: string | null,
rowCount: number,
endMark: string,
measure: string,
startMark: string,
): void {
const measuredIdentityRef = React.useRef<string | null>(null);
React.useEffect(() => {
if (
!identity ||
rowCount === 0 ||
measuredIdentityRef.current === identity
) {
return;
}
const frame = requestAnimationFrame(() => {
finishPerformanceMeasure({ startMark, endMark, measure });
measuredIdentityRef.current = identity;
});
return () => cancelAnimationFrame(frame);
}, [endMark, identity, measure, rowCount, startMark]);
}

export function useChannelRowsPaintedPerformanceMeasure(
channelId: string | null,
rowCount: number,
): void {
useRowsPaintedPerformanceMeasure(
channelId,
rowCount,
CHANNEL_ROWS_PAINTED_MARK,
CHANNEL_SWITCH_MEASURE,
CHANNEL_SWITCH_START_MARK,
);
}

export function useThreadRepliesPaintedPerformanceMeasure(
threadHeadId: string | null,
rowCount: number,
): void {
useRowsPaintedPerformanceMeasure(
threadHeadId,
rowCount,
THREAD_REPLIES_PAINTED_MARK,
THREAD_OPEN_MEASURE,
THREAD_OPEN_START_MARK,
);
}
9 changes: 8 additions & 1 deletion desktop/src/features/messages/useThreadReplies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { buildChannelReactionAuxFilter } from "@/shared/api/relayChannelFilters"
import { getThreadReplies } from "@/shared/api/tauri";
import type { Channel, RelayEvent, ThreadCursor } from "@/shared/api/types";

const THREAD_PAGE_LIMIT = 200;
const THREAD_PAGE_LIMIT = 500;
const MAX_THREAD_PAGES = 500;

/**
Expand Down Expand Up @@ -123,7 +123,11 @@ export function useThreadReplies(
if (!activeChannel || !openThreadRootId) return [];
return loadThreadReplies(queryClient, activeChannel.id, openThreadRootId);
},
// The observer stays mounted while the panel closes, so an enabled
// transition (rather than a remount) must see cached data as stale and
// start exactly one background reconciliation on every reopen.
staleTime: 0,
refetchOnMount: "always",
gcTime: 60 * 60 * 1_000,
});
}
Expand All @@ -145,7 +149,10 @@ export function useThreadRepliesForRoots(
queryKey: threadRepliesKey(channelId, rootId),
enabled: activeChannel !== null && activeChannel.channelType !== "forum",
queryFn: () => loadThreadReplies(queryClient, channelId, rootId),
// Huddle root observers are added/removed as summaries change; always
// reconcile cached subtrees when an observer returns.
staleTime: 0,
refetchOnMount: "always",
gcTime: 60 * 60 * 1_000,
})),
combine: (results) => ({
Expand Down
Loading