Smooth channel loading: single-surface timeline state machine (#1099)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-06-18 00:01:28 +00:00
committed by GitHub
co-authored by Pinky Brain
parent 5004758fbf
commit d47572209d
8 changed files with 401 additions and 62 deletions
@@ -35,6 +35,10 @@ import {
} from "@/features/messages/lib/formatTimelineMessages";
import { getThreadReference } from "@/features/messages/lib/threading";
import { imetaMediaFromTags } from "@/features/messages/lib/imetaMediaMarkdown";
import {
resolveTimelineLoadingLatch,
selectTimelineLoadingState,
} from "@/features/messages/lib/timelineLoadingState";
import { useFetchOlderMessages } from "@/features/messages/useFetchOlderMessages";
import { useLoadMissingAncestors } from "@/features/messages/useLoadMissingAncestors";
import { useChannelTyping } from "@/features/messages/useChannelTyping";
@@ -474,13 +478,27 @@ export function ChannelScreen({
setThreadReplyTargetId,
setThreadScrollTargetId,
});
const hasTimelineData = messagesQuery.data !== undefined;
const isTimelineLoading =
// `data !== undefined` is not "loaded": the cache is seeded early by stale
// placeholders and the live subscription. Wait for the history fetch to settle.
const timelineLoadingNow =
activeChannel !== null &&
activeChannel.channelType !== "forum" &&
!hasTimelineData &&
messagesQuery.isPending;
const shouldShowInitialChannelLoading = isTimelineLoading;
selectTimelineLoadingState({
isPending: messagesQuery.isPending,
isFetching: messagesQuery.isFetching,
isPlaceholderData: messagesQuery.isPlaceholderData,
dataLength: messagesQuery.data?.length ?? null,
});
// Latch loaded per channel so a later background refetch can't flip back to
// the skeleton — that re-flip is the "skeleton bouncing up and down" on entry.
const settledChannelIdRef = React.useRef<string | null>(null);
const { settledChannelId, isLoading: isTimelineLoading } =
resolveTimelineLoadingLatch(
settledChannelIdRef.current,
activeChannelId,
timelineLoadingNow,
);
settledChannelIdRef.current = settledChannelId;
// Panel identity (thread/profile/agent session) lives in the URL search
// params, so channel changes and back/forward traversals carry it per
// history entry — only the local ephemeral targets need resetting here.
@@ -607,9 +625,7 @@ export function ChannelScreen({
ref={channelContentRef}
>
{activeChannel ? (
shouldShowInitialChannelLoading ? (
<ViewLoadingFallback includeHeader kind="channel" />
) : activeChannel.channelType === "forum" ? (
activeChannel.channelType === "forum" ? (
<>
{channelHeader}
<React.Suspense fallback={<ViewLoadingFallback kind="forum" />}>
@@ -0,0 +1,112 @@
import assert from "node:assert/strict";
import test from "node:test";
import { selectTimelineLoadingState } from "./timelineLoadingState.ts";
const settled = {
isPending: false,
isFetching: false,
isPlaceholderData: false,
dataLength: null,
};
test("pending first fetch with no cache is loading", () => {
assert.equal(
selectTimelineLoadingState({ ...settled, isPending: true }),
true,
);
});
test("stale placeholder while refetching is loading", () => {
// Revisited within gcTime: placeholderData hands back a cached array while the
// authoritative fetch runs. Must keep the skeleton up, not flash the intro.
assert.equal(
selectTimelineLoadingState({
...settled,
isFetching: true,
isPlaceholderData: true,
dataLength: 0,
}),
true,
);
});
test("subscription-seeded empty cache while fetching is loading", () => {
// The live subscription's setQueryData seeds [] before history settles, so
// data is defined but empty and a fetch is still in flight.
assert.equal(
selectTimelineLoadingState({
...settled,
isFetching: true,
isPlaceholderData: false,
dataLength: 0,
}),
true,
);
});
test("settled with rows is not loading", () => {
assert.equal(
selectTimelineLoadingState({ ...settled, dataLength: 5 }),
false,
);
});
test("settled and genuinely empty is not loading (real empty channel)", () => {
assert.equal(
selectTimelineLoadingState({ ...settled, dataLength: 0 }),
false,
);
});
test("background refetch of a populated channel is not loading", () => {
// staleTime expiry can trigger a background refetch; with rows already present
// we are loaded and must not re-show the skeleton.
assert.equal(
selectTimelineLoadingState({
...settled,
isFetching: true,
dataLength: 12,
}),
false,
);
});
import { resolveTimelineLoadingLatch } from "./timelineLoadingState.ts";
test("latch: loading on first entry to a channel", () => {
const r = resolveTimelineLoadingLatch(null, "chan-a", true);
assert.equal(r.isLoading, true);
assert.equal(r.settledChannelId, null);
});
test("latch: settles when loadingNow turns false, recording the channel", () => {
const r = resolveTimelineLoadingLatch(null, "chan-a", false);
assert.equal(r.isLoading, false);
assert.equal(r.settledChannelId, "chan-a");
});
test("latch: background refetch blip stays loaded once settled", () => {
// settled for chan-a, then loadingNow blips true (isFetching) — must NOT
// re-show the skeleton (the bounce Wes reported).
const r = resolveTimelineLoadingLatch("chan-a", "chan-a", true);
assert.equal(r.isLoading, false);
assert.equal(r.settledChannelId, "chan-a");
});
test("latch: switching channels resets and loads the new one", () => {
const r = resolveTimelineLoadingLatch("chan-a", "chan-b", true);
assert.equal(r.isLoading, true);
assert.equal(r.settledChannelId, "chan-a"); // not yet settled for b
});
test("latch: no active channel passes loadingNow through untouched", () => {
assert.equal(
resolveTimelineLoadingLatch("chan-a", null, true).isLoading,
true,
);
assert.equal(
resolveTimelineLoadingLatch("chan-a", null, false).isLoading,
false,
);
});
@@ -0,0 +1,55 @@
/**
* Pure decision for "is the channel timeline still doing its initial load."
*
* Extracted so the windows below are covered by the lib `*.test.mjs` suite.
* The trap: `data !== undefined` looks like "loaded" but the per-channel query
* cache is seeded early — by a stale `placeholderData` on revisit, and by the
* live subscription's `setQueryData` — before the authoritative history fetch
* settles. Treating that as loaded flashes the channel intro/empty state over a
* list that is about to stream in.
*/
export type TimelineQueryStatus = {
isPending: boolean;
isFetching: boolean;
isPlaceholderData: boolean;
dataLength: number | null;
};
export function selectTimelineLoadingState(
status: TimelineQueryStatus,
): boolean {
if (status.isPending) {
return true;
}
// A fetch is in flight; keep loading while what we'd show is a placeholder or
// still empty. Once real rows are present we are loaded, even mid-refetch.
return (
status.isFetching &&
(status.isPlaceholderData || (status.dataLength ?? 0) === 0)
);
}
/**
* Monotonic loading latch keyed by channel. Once a channel has settled (loaded),
* `loadingNow` blipping true again (a background refetch) must not re-show the
* skeleton — that re-flip is the visible skeleton bounce on entry. A different
* channel id resets the latch so the new channel loads fresh.
*/
export function resolveTimelineLoadingLatch(
settledChannelId: string | null,
activeChannelId: string | null,
loadingNow: boolean,
): { settledChannelId: string | null; isLoading: boolean } {
if (activeChannelId === null) {
return { settledChannelId, isLoading: loadingNow };
}
if (settledChannelId === activeChannelId) {
// Already settled for this channel — stay loaded through refetch blips.
return { settledChannelId, isLoading: false };
}
if (!loadingNow) {
// First settle for this channel; latch it.
return { settledChannelId: activeChannelId, isLoading: false };
}
return { settledChannelId, isLoading: true };
}
@@ -8,6 +8,8 @@ import {
resolveDeepLinkTarget,
selectDeferredListRenderState,
selectLatestMessageKey,
selectTimelineBodySurface,
selectTimelineIntroSurface,
} from "./timelineSnapshot.ts";
// Local-midnight unix-second timestamps so isSameDay (local time) is stable
@@ -287,3 +289,96 @@ test("deferred-render: keys the empty decision off the live count, not deferred"
assert.equal(selectDeferredListRenderState(0, 0), "empty");
assert.equal(selectDeferredListRenderState(0, 1), "pending");
});
test("timeline-body-surface: loading and deferred-pending both paint the single static skeleton", () => {
assert.equal(
selectTimelineBodySurface({
deferredCount: 0,
isLoading: true,
liveCount: 0,
}),
"skeleton",
);
assert.equal(
selectTimelineBodySurface({
deferredCount: 0,
isLoading: false,
liveCount: 3,
}),
"skeleton",
);
});
test("timeline-body-surface: deferred rows paint the message list", () => {
assert.equal(
selectTimelineBodySurface({
deferredCount: 2,
isLoading: false,
liveCount: 2,
}),
"list",
);
});
test("timeline-body-surface: empty only when live and deferred rows are empty", () => {
assert.equal(
selectTimelineBodySurface({
deferredCount: 0,
isLoading: false,
liveCount: 0,
}),
"empty",
);
});
test("timeline-intro-surface: skeleton suppresses intro while loading", () => {
assert.equal(
selectTimelineIntroSurface({
hasChannelIntro: true,
hasDirectMessageIntro: false,
isSkeletonVisible: true,
}),
null,
);
});
test("timeline-intro-surface: intro may coexist with the message list", () => {
assert.equal(
selectTimelineBodySurface({
deferredCount: 2,
isLoading: false,
liveCount: 2,
}),
"list",
);
assert.equal(
selectTimelineIntroSurface({
hasChannelIntro: true,
hasDirectMessageIntro: false,
isSkeletonVisible: false,
}),
"channel-intro",
);
});
test("timeline-intro-surface: direct-message intro wins over channel intro", () => {
assert.equal(
selectTimelineIntroSurface({
hasChannelIntro: true,
hasDirectMessageIntro: true,
isSkeletonVisible: false,
}),
"direct-message-intro",
);
});
test("timeline-intro-surface: no intro without an intro model", () => {
assert.equal(
selectTimelineIntroSurface({
hasChannelIntro: false,
hasDirectMessageIntro: false,
isSkeletonVisible: false,
}),
null,
);
});
@@ -148,3 +148,51 @@ export function selectDeferredListRenderState(
}
return "pending";
}
export type TimelineBodySurface = "skeleton" | "empty" | "list";
export function selectTimelineBodySurface({
deferredCount,
isLoading,
liveCount,
}: {
deferredCount: number;
isLoading: boolean;
liveCount: number;
}): TimelineBodySurface {
if (isLoading) {
return "skeleton";
}
const renderState = selectDeferredListRenderState(deferredCount, liveCount);
if (renderState === "pending") {
return "skeleton";
}
return renderState;
}
export type TimelineIntroSurface =
| "direct-message-intro"
| "channel-intro"
| null;
export function selectTimelineIntroSurface({
hasChannelIntro,
hasDirectMessageIntro,
isSkeletonVisible,
}: {
hasChannelIntro: boolean;
hasDirectMessageIntro: boolean;
isSkeletonVisible: boolean;
}): TimelineIntroSurface {
if (isSkeletonVisible) {
return null;
}
if (hasDirectMessageIntro) {
return "direct-message-intro";
}
if (hasChannelIntro) {
return "channel-intro";
}
return null;
}
@@ -428,14 +428,7 @@ export function MessageThreadPanel({
<div className="px-3 pb-3 pt-1" data-testid="message-thread-replies">
{repliesRenderState === "list" ? (
<div
className={cn(
"space-y-2.5",
// While a deferred render is in flight the painted reply list
// lags the latest `threadReplies`. Dim it slightly so the
// streaming-in reads as intentional instead of frozen — mirrors
// the main timeline.
isRepliesPending && "opacity-60 transition-opacity",
)}
className="space-y-2.5"
data-render-pending={isRepliesPending ? "true" : undefined}
>
{deferredThreadReplies.map((entry, index) => {
@@ -1,6 +1,10 @@
import * as React from "react";
import { ArrowDown, ArrowUp, Hash } from "lucide-react";
import {
selectTimelineBodySurface,
selectTimelineIntroSurface,
} from "@/features/messages/lib/timelineSnapshot";
import { getDmParticipantPreview } from "@/features/channels/lib/dmParticipantDisplay";
import type { TimelineMessage } from "@/features/messages/types";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
@@ -9,7 +13,6 @@ import { cn } from "@/shared/lib/cn";
import { channelChrome } from "@/shared/layout/chromeLayout";
import { Button } from "@/shared/ui/button";
import { Spinner } from "@/shared/ui/spinner";
import { SkeletonReveal } from "@/shared/ui/skeleton";
import { TooltipProvider } from "@/shared/ui/tooltip";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { TimelineSkeleton, useTimelineSkeletonRows } from "./TimelineSkeleton";
@@ -167,6 +170,28 @@ export const MessageTimeline = React.memo(function MessageTimeline({
? `message-timeline:${channelId ?? "none"}:target:${targetMessageId}`
: `message-timeline:${channelId ?? "none"}`;
const timelineBodySurface = selectTimelineBodySurface({
deferredCount: deferredMessages.length,
isLoading,
liveCount: messages.length,
});
const showTimelineSkeleton = timelineBodySurface === "skeleton";
const timelineIntroSurface = selectTimelineIntroSurface({
hasChannelIntro: channelIntro !== null && directMessageIntro === null,
hasDirectMessageIntro: directMessageIntro !== null,
isSkeletonVisible: showTimelineSkeleton,
});
const showDirectMessageIntro =
timelineIntroSurface === "direct-message-intro";
const showChannelIntro = timelineIntroSurface === "channel-intro";
const activeDirectMessageIntro = showDirectMessageIntro
? directMessageIntro
: null;
const activeChannelIntro = showChannelIntro ? channelIntro : null;
const showIntro = showDirectMessageIntro || showChannelIntro;
const showGenericEmpty = timelineBodySurface === "empty" && !showIntro;
const showMessageList = timelineBodySurface === "list";
const {
bottomAnchorRef,
contentRef,
@@ -179,7 +204,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
syncScrollState,
} = useTimelineScrollManager({
channelId,
isLoading,
isLoading: showTimelineSkeleton,
messages: deferredMessages,
onTargetReached,
scrollContainerRef,
@@ -210,7 +235,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
!isUnreadPillDismissed &&
unreadCount > 0 &&
firstUnreadMessageId !== null &&
!isLoading;
!showTimelineSkeleton;
if (showUnreadPill) hasShownPillRef.current = true;
const handleJumpToOldestUnread = React.useCallback(() => {
setIsUnreadPillDismissed(true);
@@ -223,6 +248,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
const prevSearchActiveRef = React.useRef<string | null>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: scrollContainerRef is a stable React ref
React.useEffect(() => {
if (showTimelineSkeleton) return;
if (
!searchActiveMessageId ||
searchActiveMessageId === prevSearchActiveRef.current
@@ -241,31 +267,21 @@ export const MessageTimeline = React.memo(function MessageTimeline({
if (el) {
el.scrollIntoView({ block: "center", behavior: "smooth" });
}
}, [searchActiveMessageId]);
}, [searchActiveMessageId, showTimelineSkeleton]);
useLoadOlderOnScroll({
fetchOlder,
hasOlderMessages,
isLoading,
isLoading: showTimelineSkeleton,
restoreScrollPosition,
scrollContainerRef,
sentinelRef: topSentinelRef,
});
const showDirectMessageIntro = !isLoading && directMessageIntro !== null;
const showChannelIntro =
!isLoading && channelIntro !== null && directMessageIntro === null;
const showIntro = showDirectMessageIntro || showChannelIntro;
const showGenericEmpty =
!isLoading &&
deferredMessages.length === 0 &&
directMessageIntro === null &&
channelIntro === null;
const showMessageList = !isLoading && deferredMessages.length > 0;
const timelineSkeletonRows = useTimelineSkeletonRows({
channelId,
isLoading,
messages,
isLoading: showTimelineSkeleton,
messages: showTimelineSkeleton ? EMPTY_MESSAGES : deferredMessages,
});
return (
@@ -317,41 +333,38 @@ export const MessageTimeline = React.memo(function MessageTimeline({
</div>
) : null}
<SkeletonReveal
<div
className={cn(
"min-h-[18rem]",
"flex min-h-[18rem] min-w-0 flex-col gap-2",
(showIntro || showGenericEmpty) && "min-h-full",
showMessageList && !showIntro && "mt-auto",
)}
contentClassName={cn(
"flex min-w-0 flex-col gap-2",
(showIntro || showGenericEmpty) && "min-h-full",
)}
loading={isLoading}
skeleton={<TimelineSkeleton rows={timelineSkeletonRows} />}
>
{showDirectMessageIntro ? (
{showTimelineSkeleton ? (
<TimelineSkeleton rows={timelineSkeletonRows} />
) : null}
{activeDirectMessageIntro ? (
<div
className="mb-0.5 mt-auto flex w-full flex-col items-start px-3 py-2 text-left"
data-testid="message-dm-intro"
>
<DirectMessageIntroAvatarStack
participants={directMessageIntro.participants}
participants={activeDirectMessageIntro.participants}
/>
<p className="mt-4 max-w-full truncate text-xl font-semibold leading-7 tracking-tight text-foreground">
{directMessageIntro.displayName}
{activeDirectMessageIntro.displayName}
</p>
<p className="mt-1 max-w-full truncate whitespace-nowrap text-sm leading-5 text-muted-foreground">
This is the beginning of your direct message with{" "}
<span className="font-medium text-foreground">
{directMessageIntro.displayName}
{activeDirectMessageIntro.displayName}
</span>
.
</p>
</div>
) : null}
{showChannelIntro ? (
{activeChannelIntro ? (
<div
className="mb-0.5 mt-auto flex w-full max-w-2xl flex-col items-start px-3 py-2 text-left"
data-testid="message-channel-intro"
@@ -360,28 +373,28 @@ export const MessageTimeline = React.memo(function MessageTimeline({
className="flex h-[60px] w-[60px] items-center justify-center rounded-2xl border border-border/70 bg-muted/40 text-muted-foreground"
data-testid="message-channel-intro-icon"
>
{channelIntro.icon ?? (
{activeChannelIntro.icon ?? (
<Hash aria-hidden className="h-7 w-7" />
)}
</div>
<p className="mt-4 max-w-full truncate text-xl font-semibold leading-7 tracking-tight text-foreground">
#{channelIntro.channelName}
#{activeChannelIntro.channelName}
</p>
<p className="mt-1 max-w-full text-sm leading-5 text-muted-foreground">
This is the beginning of the{" "}
<span className="font-medium text-foreground">
{channelIntro.channelKindLabel}
{activeChannelIntro.channelKindLabel}
</span>
.
</p>
{channelIntro.description ? (
{activeChannelIntro.description ? (
<p className="mt-2 max-w-xl text-sm leading-5 text-muted-foreground">
{channelIntro.description}
{activeChannelIntro.description}
</p>
) : null}
{channelIntro.actions?.length ? (
{activeChannelIntro.actions?.length ? (
<div className="mt-4 flex max-w-full flex-nowrap gap-3 overflow-x-auto pb-1">
{channelIntro.actions.map((action) => {
{activeChannelIntro.actions.map((action) => {
const hasDescription = Boolean(action.description);
return (
@@ -460,14 +473,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
{showMessageList ? (
<div
className={cn(
"flex flex-col gap-2",
!showIntro && "mt-auto",
// While a deferred render is in flight the painted
// list lags the latest `messages`. Dim it slightly so the
// streaming-in feels intentional instead of frozen.
isRenderPending && "opacity-60 transition-opacity",
)}
className={cn("flex flex-col gap-2", !showIntro && "mt-auto")}
data-render-pending={isRenderPending ? "true" : undefined}
>
<TimelineMessageList
@@ -499,7 +505,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
/>
</div>
) : null}
</SkeletonReveal>
</div>
<div aria-hidden className="h-px" ref={bottomAnchorRef} />
</div>
@@ -41,6 +41,11 @@ export function useTimelineScrollManager({
const previousLastMessageKeyRef = React.useRef<string | undefined>(undefined);
const previousMessageCountRef = React.useRef(0);
const handledTargetMessageIdRef = React.useRef<string | null>(null);
// Mirror isLoading into a ref so the ResizeObservers (which subscribe once)
// can skip reacting while the skeleton is up — reacting to height churn under
// a streaming-in list is what makes the timeline thrash on entry.
const isLoadingRef = React.useRef(isLoading);
isLoadingRef.current = isLoading;
const [isAtBottom, setIsAtBottom] = React.useState(true);
const [highlightedMessageId, setHighlightedMessageId] = React.useState<
string | null
@@ -241,6 +246,12 @@ export function useTimelineScrollManager({
const nextTimelineHeight = entry.contentRect.height;
previousTimelineHeightRef.current = nextTimelineHeight;
// Track height while loading, but don't scroll — the init layout-effect
// owns the first scroll once content settles.
if (isLoadingRef.current) {
return;
}
if (
previousTimelineHeight === null ||
Math.abs(nextTimelineHeight - previousTimelineHeight) < 1
@@ -271,6 +282,9 @@ export function useTimelineScrollManager({
}
const observer = new ResizeObserver(() => {
if (isLoadingRef.current) {
return;
}
if (shouldStickToBottomRef.current) {
scrollToBottom("auto");
return;