diff --git a/src/web/src/app/c/me/[dmId]/page.dismissal.dom.test.ts b/src/web/src/app/c/me/[dmId]/page.dismissal.dom.test.ts index 4e4fee4be..af647a14a 100644 --- a/src/web/src/app/c/me/[dmId]/page.dismissal.dom.test.ts +++ b/src/web/src/app/c/me/[dmId]/page.dismissal.dom.test.ts @@ -36,6 +36,8 @@ const { vi.mock("next/navigation", () => ({ useParams: () => ({ dmId: "dm_1" }), useSearchParams: () => new URLSearchParams(), + usePathname: () => "/c/me/dm_1", + useRouter: () => ({ replace: vi.fn() }), })) vi.mock("sonner", () => ({ toast: vi.fn() })) vi.mock("@/hooks/use-mobile", () => ({ useBreakpoint: () => "desktop" })) diff --git a/src/web/src/components/community/channels/channel-route.text-scroll-target.dom.test.ts b/src/web/src/components/community/channels/channel-route.text-scroll-target.dom.test.ts index ac91b7f6f..cb97fb704 100644 --- a/src/web/src/components/community/channels/channel-route.text-scroll-target.dom.test.ts +++ b/src/web/src/components/community/channels/channel-route.text-scroll-target.dom.test.ts @@ -7,6 +7,7 @@ import { ForumChannelSurface } from "./forum-channel-surface" import { MessageList } from "../messages/message-list" import { useChannelMemberViewModel } from "../members/channel-member-view-model" import { useChannelMessageFeed } from "@/hooks/community/use-channel-message-feed" +import type { ConversationNavigationTarget } from "@/lib/community/conversation-navigation-proof" const { mockRouteModel, @@ -17,6 +18,7 @@ const { mockHeaderServerNavigate, mockHeaderParentNavigate, mockOpenerGate, + mockForumOpener, mockSearchParams, mockSplitMode, mockSplitParentSurface, @@ -34,13 +36,14 @@ const { mockHeaderServerNavigate: { current: undefined as undefined | (() => void) }, mockHeaderParentNavigate: { current: undefined as undefined | (() => void) }, mockOpenerGate: vi.fn(() => null), + mockForumOpener: { data: null as null | { content: string }, isLoading: false, isError: false, error: null as Error | null, isFetching: false, refetch: vi.fn(() => Promise.resolve()) }, mockSearchParams: { value: "msg=m_target&keep=1" }, mockSplitMode: { value: "full" as "split" | "full" }, mockSplitParentSurface: vi.fn(() => null), mockCommitLastCommunityRoute: vi.fn(), mockSetLastChannel: vi.fn(), mockClearLastChannel: vi.fn(), - mockNavigationGate: { allowed: true }, + mockNavigationGate: { allowed: true, target: null as ConversationNavigationTarget | null }, mockCurrentChannelId: { value: "channel_1" as string | null }, mockCanManageServer: vi.fn((role?: string | null) => role === "owner" || role === "admin"), mockDismissConversation: vi.fn(), @@ -64,6 +67,9 @@ const { isChild: false, isForumPostChild: false, isNotifyUnit: false, + serverError: false, + retryingServer: false, + retryServer: vi.fn(), metadataError: false, retryingMetadata: false, retryMetadata: vi.fn(), @@ -91,7 +97,7 @@ vi.mock("next/navigation", () => ({ useSearchParams: () => new URLSearchParams(mockSearchParams.value), })) vi.mock("@/lib/community/conversation-navigation-proof", async (importOriginal) => ({ ...await importOriginal(), - useConversationNavigationGate: () => ({ required: false, allowed: mockNavigationGate.allowed }), + useConversationNavigationGate: () => ({ required: false, allowed: mockNavigationGate.allowed, target: mockNavigationGate.target }), })) vi.mock("sonner", () => ({ toast: vi.fn() })) vi.mock("@/lib/api/client", () => ({ apiFetch: vi.fn(), toastApiError: vi.fn() })) @@ -230,7 +236,7 @@ vi.mock("@/components/community/channels/thread-split-view", () => ({ ), })) vi.mock("@/hooks/community/use-forum-opener-hint", () => ({ - useForumOpenerHint: () => ({ data: null, isLoading: false }), + useForumOpenerHint: () => mockForumOpener, })) vi.mock("@/hooks/community/use-server-members", () => ({ useServerMembers: () => ({ @@ -349,6 +355,8 @@ describe("ChannelRoute message surface ownership", () => { vi.useFakeTimers() mockedMessageList.mockClear() mockOpenerGate.mockClear() + Object.assign(mockForumOpener, { data: null, isLoading: false, isError: false, error: null, isFetching: false }) + mockForumOpener.refetch.mockClear() mockSearchParams.value = "msg=m_target&keep=1" mockSplitMode.value = "full" mockSplitParentSurface.mockClear() @@ -360,6 +368,7 @@ describe("ChannelRoute message surface ownership", () => { mockClearLastChannel.mockClear() mockDismissConversation.mockClear() mockNavigationGate.allowed = true + mockNavigationGate.target = null mockCurrentChannelId.value = "channel_1" mockMemberViewModel.myRole = "member" Object.assign(mockRouteModel, { @@ -376,6 +385,8 @@ describe("ChannelRoute message surface ownership", () => { isChild: false, isForumPostChild: false, isNotifyUnit: false, + serverError: false, + retryingServer: false, metadataError: false, retryingMetadata: false, routeHydrated: true, @@ -496,6 +507,39 @@ describe("ChannelRoute message surface ownership", () => { expect(mockCommitLastCommunityRoute).not.toHaveBeenCalled() }) + it("shows a required cold forum opener failure, keeps Retry feedback while pending, then opens body", async () => { + configureThreadRoute() + mockRouteModel.isForumPostChild = true + Object.assign(mockForumOpener, { isError: true, error: new Error("deadline") }) + let release!: () => void + mockForumOpener.refetch.mockImplementationOnce(() => new Promise((resolve) => { release = resolve })) + const props = { serverParam: "server_1", channelId: "channel_1" } + const renderer = render(React.createElement(ChannelRoute, props)) + expect(screen.getByRole("button", { name: "Retry" })).not.toBeDisabled() + expect(mockedUseChannelMessageFeed).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole("button", { name: "Retry" })) + expect(mockForumOpener.refetch).toHaveBeenCalledOnce() + Object.assign(mockForumOpener, { isError: false, error: null, isFetching: true, isLoading: true }) + renderer.rerender(React.createElement(ChannelRoute, props)) + expect(screen.getByRole("button", { name: "Retrying…" })).toBeDisabled() + await act(async () => { release() }) + Object.assign(mockForumOpener, { data: { content: "Recovered title" }, isFetching: false, isLoading: false }) + mockedUseChannelMessageFeed.mockReturnValue(feed()) + renderer.rerender(React.createElement(ChannelRoute, props)) + expect(screen.queryByRole("alert")).toBeNull() + expect(mockedUseChannelMessageFeed).toHaveBeenCalled() + renderer.unmount() + }) + + it("shows a server dependency error before unknown skeleton and forwards Retry", () => { + Object.assign(mockRouteModel, { serverError: true, routeLifecycle: "terminal-error", routeHydrated: false, skeletonSubtype: "unknown" }) + const renderer = render(React.createElement(ChannelRoute, { serverParam: "server_1", channelId: "channel_1" })) + fireEvent.click(screen.getByRole("button", { name: "Retry" })) + expect(mockRouteModel.retryServer).toHaveBeenCalledOnce() + expect(mockedUseChannelMessageFeed).not.toHaveBeenCalled() + renderer.unmount() + }) + it("renders the terminal metadata error without opening a feed and forwards Retry", async () => { Object.assign(mockRouteModel, { isChild: true, routeLifecycle: "terminal-error", routeHydrated: false, @@ -800,6 +844,35 @@ describe("ChannelRoute message surface ownership", () => { vi.clearAllMocks() }) + it.each(["text", "thread"])("hands the current Marked anchor to the %s feed before mount revalidation", (kind) => { + if (kind === "thread") configureThreadRoute() + mockSearchParams.value = "keep=1" + mockedUseChannelMessageFeed.mockReturnValue(feed()) + const route = () => React.createElement(ChannelRoute, { serverParam: "server_1", channelId: "channel_1" }) + const renderer = render(route()) + for (const anchorMessageId of ["first-marked", "second-marked", "second-marked"]) { + mockNavigationGate.allowed = false + mockNavigationGate.target = { href: "/c/channels/server_1/channel_1?seq=7", + viewerId: "viewer_1", channelId: "channel_1", serverId: "server_1", scopeKind: "channel", anchorMessageId } + renderer.rerender(route()) + mockedUseChannelMessageFeed.mockClear() + mockNavigationGate.allowed = true + renderer.rerender(route()) + expect(mockedUseChannelMessageFeed.mock.calls[0]?.[0].anchorMessageId).toBe(anchorMessageId) + mockNavigationGate.target = null + renderer.rerender(route()) + expect(mockedUseChannelMessageFeed.mock.lastCall?.[0].anchorMessageId).toBe(anchorMessageId) + } + mockNavigationGate.target = { href: "/c/channels/server_1/channel_1", + viewerId: "viewer_1", channelId: "channel_1", serverId: "server_1", scopeKind: "channel" } + renderer.rerender(route()) + expect(mockedUseChannelMessageFeed.mock.lastCall?.[0].anchorMessageId).toBeNull() + mockNavigationGate.target = null + renderer.rerender(route()) + expect(mockedUseChannelMessageFeed.mock.lastCall?.[0].anchorMessageId).toBeNull() + expect(mockRouter.replace).not.toHaveBeenCalled() + }) + it("keeps the route anchor until MessageList reports a successful jump", async () => { let surfaceFeed = feed({ messages: [{ id: "m_unrelated" }] }) mockedUseChannelMessageFeed.mockImplementation(() => surfaceFeed) diff --git a/src/web/src/components/community/channels/channel-route.tsx b/src/web/src/components/community/channels/channel-route.tsx index c174d09a5..ce4d79552 100644 --- a/src/web/src/components/community/channels/channel-route.tsx +++ b/src/web/src/components/community/channels/channel-route.tsx @@ -3,7 +3,7 @@ import { useAtom, useCreateAtom } from "@tanstack/react-store"; import { getCommunityRuntime } from "@/stores/community/runtime" -import { useCallback, useEffect, useLayoutEffect, useMemo } from "react" +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react" import { useRouter, useSearchParams } from "next/navigation" import { toastApiError } from "@/lib/api/client" import { ChannelHeaderSkeleton, type ChannelNotifLevel } from "@/components/community/channels/channel-header" @@ -40,6 +40,7 @@ import { useQueryClient } from "@tanstack/react-query" import { useCommunityWsStore } from "@/stores/community/ws" import { useConversationNavigationGate } from "@/lib/community/conversation-navigation-proof" import { resolveConversationSubtype } from "@/lib/community/conversation-subtype" +import { isConversationAccessError } from "@/lib/community/conversation-read" import { useNativeSystemNotificationConversationDismissal } from "@/hooks/community/use-native-system-notifications" const THREAD_VIEW_PARAM = "threadView" @@ -59,12 +60,7 @@ export function ChannelRoute({ serverParam, channelId }: { const searchParams = useSearchParams() const serverId = decodeURIComponent(serverParam) const currentUser = useCurrentUser() - // Cross-channel "jump to message" target, captured ONCE at mount from `?msg=`. - // `ChannelView` is keyed by `serverId/channelId`, so a fresh jump remounts and - // re-reads this. The param is stripped from the URL right after (below) so a - // refresh/back doesn't re-trigger the jump; this frozen copy still drives the - // anchor + scroll for this mount. -const [jumpTargetId] = useAtom(useCreateAtom((() => searchParams.get("msg"))())) + const [jumpTargetId, setJumpTargetId] = useAtom(useCreateAtom((() => searchParams.get("msg"))())) const queryClient = useQueryClient() const accessEpoch = useCommunityWsStore((state) => state.accessEpoch) const navigationGate = useConversationNavigationGate( @@ -73,6 +69,12 @@ const [jumpTargetId] = useAtom(useCreateAtom((() => searchParams. channelId, accessEpoch, ) + const navigationTarget = navigationGate.target + const currentAnchorMessageId = navigationTarget + ? navigationTarget.anchorMessageId ?? null : jumpTargetId + useLayoutEffect(() => { + if (navigationTarget) setJumpTargetId(navigationTarget.anchorMessageId ?? null) + }, [navigationTarget, setJumpTargetId]) const uiHandlers = useUiHandlers() const currentChannelId = useCurrentChannelId() const routeModel = useChannelRouteModel(serverId, serverParam, channelId, currentUser.id) @@ -108,6 +110,24 @@ const [topLevelRouteOwnership, setTopLevelRouteOwnership] = useAtom(useCreateAto currentChannelMeta?.parentMessageId, isForumPostChild && routeModel.routeHydrated && navigationGate.allowed, ) + const { isFetching: fetchingOpener, refetch: refetchOpener } = forumPostOpener + const openerScope = JSON.stringify([currentUser.id, serverId, channelId, currentChannelMeta?.parentMessageId, accessEpoch]) + const [openerRetryAttempt, setOpenerRetryAttempt] = useAtom(useCreateAtom<{ scope: string } | null>(null)) + const openerRetryRef = useRef<{ scope: string } | null>(null) + const retryingOpener = openerRetryAttempt?.scope === openerScope + const openerError = isForumPostChild && (isConversationAccessError(forumPostOpener.error) + || (!forumPostOpener.data && (forumPostOpener.isError || retryingOpener))) + const retryOpener = useCallback(async () => { + if (!openerError || fetchingOpener || openerRetryRef.current?.scope === openerScope) return + const attempt = { scope: openerScope } + openerRetryRef.current = attempt + setOpenerRetryAttempt(attempt) + try { await refetchOpener({ cancelRefetch: false }) } + finally { + if (openerRetryRef.current === attempt) openerRetryRef.current = null + setOpenerRetryAttempt((current) => current === attempt ? null : current) + } + }, [fetchingOpener, refetchOpener, openerError, openerScope, setOpenerRetryAttempt]) const threadOpenerHandoff = useThreadOpenerRouteGate({ serverId, childChannelId: channelId, @@ -185,11 +205,8 @@ const [topLevelRouteOwnership, setTopLevelRouteOwnership] = useAtom(useCreateAto }) }, [channelId, setChannelNotif]) - // Strip `?msg=` from the URL right after mount so a refresh/back doesn't - // re-trigger the jump. The frozen `jumpTargetId` still seeds the mounted - // message controller for this mount; this only cleans the address. useEffect(() => { - if (!jumpTargetId || searchParams.has(THREAD_OPENER_HANDOFF_PARAM)) return + if (!jumpTargetId || !searchParams.has("msg") || searchParams.has(THREAD_OPENER_HANDOFF_PARAM)) return const search = searchParams.toString() const routePath = channelHref(serverParam, channelId) const href = `${routePath}${search ? `?${search}` : ""}` @@ -219,7 +236,7 @@ const [topLevelRouteOwnership, setTopLevelRouteOwnership] = useAtom(useCreateAto routeModel.routeLifecycle === "ready" && currentChannelId === channelId && routeModel.routeHydrated && - (!isForumPostChild || !forumPostOpener.isLoading) && + (!isForumPostChild || (!forumPostOpener.isLoading && !openerError)) && navigationGate.allowed useNativeSystemNotificationConversationDismissal(currentUser.id, { kind: "server", @@ -240,12 +257,23 @@ const [topLevelRouteOwnership, setTopLevelRouteOwnership] = useAtom(useCreateAto isForum, structuralHint: routeModel.skeletonSubtype, }) + if (navigationGate.failed) { + return + } + if (routeModel.serverError) { + return { void routeModel.retryServer() }} /> + } if (routeModel.metadataError) { return { void routeModel.retryMetadata() }} /> } + if (openerError) { + return { void retryOpener() }} /> + } if (subtype === "unknown") { return } @@ -335,7 +363,7 @@ const [topLevelRouteOwnership, setTopLevelRouteOwnership] = useAtom(useCreateAto channelName={channelName} viewer={currentUser} canManagePins={canManageServer(myRole)} - anchorMessageId={jumpTargetId} + anchorMessageId={currentAnchorMessageId} parentChannelId={currentChannelMeta?.parentChannelId ?? null} parentMessageId={currentChannelMeta?.parentMessageId ?? null} parentIsForum={isForumPostChild} @@ -396,7 +424,7 @@ const [topLevelRouteOwnership, setTopLevelRouteOwnership] = useAtom(useCreateAto channelName={channelName} viewer={currentUser} canManagePins={canManageServer(myRole)} - anchorMessageId={jumpTargetId} + anchorMessageId={currentAnchorMessageId} onNavigateParent={navigateServerRoot} notificationLevel={(channelNotif[channelId] as ChannelNotifLevel) ?? USE_SERVER_DEFAULT} onSetNotificationLevel={setNotificationLevel} diff --git a/src/web/src/components/community/channels/dm-view.tsx b/src/web/src/components/community/channels/dm-view.tsx index 2e9eca339..a6067b64f 100644 --- a/src/web/src/components/community/channels/dm-view.tsx +++ b/src/web/src/components/community/channels/dm-view.tsx @@ -3,7 +3,6 @@ import { useCallback, useEffect, useMemo, useState } from "react" import { useAtom, useCreateAtom } from "@tanstack/react-store" import { useCommunityRuntime } from "@/stores/community/runtime" -import { useSearchParams } from "next/navigation" import { toast } from "sonner" import { useBreakpoint } from "@/hooks/use-mobile" import { DmHeader } from "@/components/community/channels/dm-header" @@ -63,9 +62,14 @@ import { } from "@/lib/community-db/projections" import { useNativeSystemNotificationConversationDismissal } from "@/hooks/community/use-native-system-notifications" import { commitCommunityChannelRoute } from "@/lib/community/last-community-route" +import { useQueryClient } from "@tanstack/react-query" +import { useConversationNavigationGate } from "@/lib/community/conversation-navigation-proof" +import { useCommunityWsStore } from "@/stores/community/ws" import { useChannelMetadata } from "@/hooks/community/use-channel-metadata" import { isChannelMetadataTokenCurrent } from "@/hooks/community/channel-metadata" import { ConversationResolutionErrorFrame } from "./conversation-resolution-error-frame" +import { isConversationAccessError } from "@/lib/community/conversation-read" +import { useDmSeqContext } from "./use-dm-seq-context" function resolveDmLoadingOwnership({ hasDm, @@ -87,6 +91,9 @@ export function DmView({ dmId }: { dmId: string }) { const communityRuntime = useCommunityRuntime() const bp = useBreakpoint() const currentUser = useCurrentUser() + const queryClient = useQueryClient() + const accessEpoch = useCommunityWsStore((state) => state.accessEpoch) + const navigationGate = useConversationNavigationGate(queryClient, currentUser.id, dmId, accessEpoch) const metadata = useChannelMetadata(null, dmId) const uiHandlers = useUiHandlers() const notifications = useNotificationSettings() @@ -152,6 +159,8 @@ export function DmView({ dmId }: { dmId: string }) { latestSeq, isPending: messagesPending, isError: messagesError, + error: messagesLoadError, + isFetching: messagesFetching, refetch: refetchMessages, navigationBlocked, anchorReconciled, @@ -169,19 +178,14 @@ export function DmView({ dmId }: { dmId: string }) { viewerUserId: currentUser.id, }) const messages = useMemo(() => historyAllowed ? fetchedMessages : [], [fetchedMessages, historyAllowed]) + const initialLoadError = isConversationAccessError(messagesLoadError) + ? messagesLoadError : readError ?? messagesLoadError - // Cross-navigation deep-link: a Marked-tab row for a DM message navigates - // here with `?seq=` and we open the context sheet on that message. Read - // once at mount (frozen), mirroring the channel page's `?msg=` — a - // refresh/back doesn't re-trigger it. The DM view has no in-place scroll - // anchor, so the context sheet (seq → id + surrounding window) is the jump. - const searchParams = useSearchParams() - const [initialSeq] = useState(() => { - const raw = searchParams.get("seq") - const n = raw ? Number(raw) : NaN - return Number.isFinite(n) ? n : null + const [contextSheetSeq, setContextSheetSeq] = useAtom(useCreateAtom(null)) + useDmSeqContext({ dmId, historyAllowed, + navigationAllowed: navigationGate.allowed && !navigationBlocked, + setContextSeq: setContextSheetSeq, }) - const [contextSheetSeq, setContextSheetSeq] = useAtom(useCreateAtom(initialSeq)) // DM composer has no "current server" — flatten every member server's // channels into one cross-server candidate list so a `/`-ref can be // dropped into a DM (see plan community-channel-ref.md §6). @@ -429,6 +433,9 @@ export function DmView({ dmId }: { dmId: string }) { channelId: dmId, }, routeReady) + if (navigationGate.failed) { + return + } if (navigationBlocked) { return } @@ -464,6 +471,9 @@ export function DmView({ dmId }: { dmId: string }) { channel={dm.name} messages={messages} loading={!historyAllowed || loadingOwnership.messageBodyLoading} + initialLoadError={initialLoadError} + retryingInitialLoad={retryingRead || messagesFetching} + onRetryInitialLoad={() => { if (initialLoadError && initialLoadError === messagesLoadError) void refetchMessages({ cancelRefetch: false }); else retryRead() }} newDividerBefore={newDividerBefore} onOpenThread={() => { }} onToggleReaction={dmBlocked ? undefined : messageActions.onToggleReaction} diff --git a/src/web/src/components/community/channels/forum-surface.dom.test.ts b/src/web/src/components/community/channels/forum-surface.dom.test.ts index 9df9d1f76..57a0017d6 100644 --- a/src/web/src/components/community/channels/forum-surface.dom.test.ts +++ b/src/web/src/components/community/channels/forum-surface.dom.test.ts @@ -1,11 +1,13 @@ import React from "react" import { beforeEach, describe, expect, it, vi } from "vitest" -import { act, render } from "@/test/react-dom-harness" +import { act, fireEvent, render, screen } from "@/test/react-dom-harness" +import { ApiError } from "@/lib/errors" import { ForumView } from "./forum-view" import { ForumSurface } from "./forum-surface" const mocks = vi.hoisted(() => ({ observe: vi.fn(), + read: { snapshot: { lastReadMessageId: "opener-old", lastReadAt: "t", lastReadSeq: 2 }, isFetching: false, error: null as Error | null, retrying: false, retry: vi.fn(() => Promise.resolve()) }, feed: { posts: [ { @@ -39,6 +41,8 @@ const mocks = vi.hoisted(() => ({ participantCount: 0, }, ], + error: null as Error | null, + isFetching: false, isLoading: false, isPending: false, isError: false, @@ -52,14 +56,13 @@ const mocks = vi.hoisted(() => ({ }, })) +vi.mock("@tanstack/react-query", async (load) => ({ ...await load(), useQueryClient: () => ({ refetchQueries: mocks.read.retry }) })) + vi.mock("@/hooks/community/use-forum-feed", () => ({ useForumFeed: () => mocks.feed, })) vi.mock("@/hooks/community/use-channel-read-state", () => ({ - useChannelReadStateSnapshot: () => ({ - snapshot: { lastReadMessageId: "opener-old", lastReadAt: "t", lastReadSeq: 2 }, - isFetching: false, - }), + useChannelReadStateSnapshot: () => mocks.read, })) vi.mock("@/hooks/community/use-read-observer", () => ({ useTimelineReadObserver: (value: unknown) => mocks.observe(value), @@ -69,7 +72,54 @@ vi.mock("./forum-view", () => ({ })) describe("ForumSurface generic read-row adapter", () => { - beforeEach(() => vi.clearAllMocks()) + it("keeps cold failure Retry visible through a deduplicated retry and recovers to the list", async () => { + Object.assign(mocks.feed, { posts: [], isError: true, error: new Error("deadline") }) + let release!: () => void + mocks.feed.refetch.mockImplementationOnce(() => new Promise((resolve) => { release = resolve })) + const props = { serverId: "server-1", forumChannelId: "forum-1", members: [], onOpenPost: vi.fn() } + const rendered = render(React.createElement(ForumSurface, props)) + expect(screen.getByRole("alert")).toBeDefined() + fireEvent.click(screen.getByRole("button", { name: "Retry" })) + expect(mocks.feed.refetch).toHaveBeenCalledOnce() + expect(mocks.read.retry).toHaveBeenCalledOnce() + Object.assign(mocks.feed, { isError: false, error: null, isFetching: true }) + rendered.rerender(React.createElement(ForumSurface, props)) + expect(screen.getByRole("button", { name: "Retrying…" })).toBeDisabled() + fireEvent.click(screen.getByRole("button", { name: "Retrying…" })) + expect(mocks.feed.refetch).toHaveBeenCalledOnce() + await act(async () => { release() }) + Object.assign(mocks.feed, { posts: mocks.feed.posts.concat(posts), isFetching: false }) + rendered.rerender(React.createElement(ForumSurface, props)) + expect(screen.queryByRole("alert")).toBeNull() + expect(ForumView).toHaveBeenLastCalledWith(expect.objectContaining({ posts }), undefined) + rendered.unmount() + }) + + it("retains readable posts during transient feed and read failures", () => { + Object.assign(mocks.feed, { isError: true, error: new Error("offline") }) + mocks.read.error = new Error("offline") + const rendered = render(React.createElement(ForumSurface, { serverId: "server-1", forumChannelId: "forum-1", members: [], onOpenPost: vi.fn() })) + expect(screen.queryByRole("alert")).toBeNull() + expect(ForumView).toHaveBeenCalled() + rendered.unmount() + }) + + it.each(["feed", "read"])("suppresses cached posts and watermark after %s access denial", (source) => { + if (source === "feed") Object.assign(mocks.feed, { isError: true, error: new ApiError("denied", 403) }) + else mocks.read.error = new ApiError("denied", 403) + const rendered = render(React.createElement(ForumSurface, { serverId: "server-1", forumChannelId: "forum-1", members: [], onOpenPost: vi.fn() })) + expect(screen.getByRole("alert")).toBeDefined() + expect(ForumView).not.toHaveBeenCalled() + expect(mocks.observe).toHaveBeenLastCalledWith(expect.objectContaining(source === "feed" ? { feedStatus: "error" } : { snapshotStatus: "error" })) + rendered.unmount() + }) + + const posts = mocks.feed.posts + beforeEach(() => { + vi.clearAllMocks() + Object.assign(mocks.feed, { posts, error: null, isError: false, isFetching: false, isLoading: false, isPending: false }) + Object.assign(mocks.read, { error: null, isFetching: false, retrying: false }) + }) it("projects canonical opener candidates and binds only the list viewport", () => { const rendered = render(React.createElement(ForumSurface, { diff --git a/src/web/src/components/community/channels/forum-surface.tsx b/src/web/src/components/community/channels/forum-surface.tsx index ad0ad5ea0..cde30c12f 100644 --- a/src/web/src/components/community/channels/forum-surface.tsx +++ b/src/web/src/components/community/channels/forum-surface.tsx @@ -1,5 +1,8 @@ "use client" +import { useQueryClient } from "@tanstack/react-query" +import { communityKeys } from "@/lib/query-keys" +import { useRef } from "react" import { useAtom, useCreateAtom } from "@tanstack/react-store"; import type { NewForumThread } from "../messages/create-forum-thread" @@ -10,6 +13,8 @@ import { ForumView } from "./forum-view" import { useForumFeed } from "@/hooks/community/use-forum-feed" import { useChannelReadStateSnapshot } from "@/hooks/community/use-channel-read-state" import { useTimelineReadObserver } from "@/hooks/community/use-read-observer" +import { isConversationAccessError } from "@/lib/community/conversation-read" +import { ConversationResolutionErrorFrame } from "./conversation-resolution-error-frame" export function ForumSurface({ serverId, forumChannelId, ...props }: { serverId: string @@ -25,9 +30,18 @@ export function ForumSurface({ serverId, forumChannelId, ...props }: { canDeletePost?: (post: ForumThread) => boolean deletingPost?: string | null }) { + const queryClient = useQueryClient() const feed = useForumFeed(serverId, forumChannelId) const readState = useChannelReadStateSnapshot(forumChannelId) const [scrollRootEl, setScrollRootEl] = useAtom(useCreateAtom(null)) + const retryScope = JSON.stringify([forumChannelId, feed.tag]) + const [retryAttempt, setRetryAttempt] = useAtom(useCreateAtom<{ scope: string } | null>(null)) + const retryRef = useRef<{ scope: string } | null>(null) + const retrying = retryAttempt?.scope === retryScope + const accessError = isConversationAccessError(feed.error) || isConversationAccessError(readState.error) + const initialLoadError = accessError || (feed.posts.length === 0 && ( + feed.isError || !!readState.error || retrying + )) useTimelineReadObserver({ channelId: forumChannelId, messages: feed.posts.flatMap((post) => ( @@ -41,7 +55,9 @@ export function ForumSurface({ serverId, forumChannelId, ...props }: { : [] )), scrollRootEl, - snapshotStatus: readState.isFetching + snapshotStatus: isConversationAccessError(readState.error) + ? "error" + : readState.isFetching ? "pending" : readState.snapshot ? "ready" @@ -55,6 +71,18 @@ export function ForumSurface({ serverId, forumChannelId, ...props }: { confirmedSeq: readState.snapshot?.lastReadSeq ?? 0, catchUp: () => feed.refetch(), }) + if (initialLoadError) { + return { + if (retrying || retryRef.current?.scope === retryScope || feed.isFetching || readState.retrying) return + const attempt = { scope: retryScope } + retryRef.current = attempt + setRetryAttempt(attempt) + void Promise.all([feed.refetch({ cancelRefetch: false }), queryClient.refetchQueries({ queryKey: communityKeys.channelReadStateSnapshot(forumChannelId), exact: true }, { cancelRefetch: false })]).finally(() => { + if (retryRef.current === attempt) retryRef.current = null + setRetryAttempt((current) => current === attempt ? null : current) + }) + }} /> + } return ({ + pathname: "/c/me/dm1", + search: "", + router: { replace: vi.fn() }, +})) +vi.mock("next/navigation", () => ({ + usePathname: () => navigation.pathname, + useSearchParams: () => new URLSearchParams(navigation.search), + useRouter: () => navigation.router, +})) + +function Context({ dmId = "dm1", historyAllowed = true, navigationAllowed = true }: { + dmId?: string + historyAllowed?: boolean + navigationAllowed?: boolean +}) { + const [seq, setContextSeq] = useAtom(useCreateAtom(null)) + useDmSeqContext({ dmId, historyAllowed, navigationAllowed, setContextSeq }) + return createElement("div", null, + createElement("output", null, seq === null ? "closed" : `target ${seq}`), + createElement("button", { onClick: () => setContextSeq(null) }, "Close"), + ) +} + +beforeEach(() => { + navigation.pathname = "/c/me/dm1" + navigation.search = "" + navigation.router.replace.mockReset() +}) + +describe("mounted DM sequence URL intents", () => { + it("consumes initial, different and repeated targets after ordinary Close", () => { + navigation.search = "seq=39&panel=details" + const view = render(createElement(Context)) + expect(screen.getByText("target 39")).toBeTruthy() + expect(navigation.router.replace).toHaveBeenLastCalledWith("/c/me/dm1?panel=details", { scroll: false }) + + navigation.search = "panel=details" + view.rerender(createElement(Context)) + fireEvent.click(screen.getByText("Close")) + expect(screen.getByText("closed")).toBeTruthy() + view.rerender(createElement(Context)) + expect(screen.getByText("closed")).toBeTruthy() + expect(navigation.router.replace).toHaveBeenCalledTimes(1) + + navigation.search = "seq=105&panel=details" + view.rerender(createElement(Context)) + expect(screen.getByText("target 105")).toBeTruthy() + navigation.search = "panel=details" + view.rerender(createElement(Context)) + fireEvent.click(screen.getByText("Close")) + navigation.search = "seq=105&panel=details" + view.rerender(createElement(Context)) + expect(screen.getByText("target 105")).toBeTruthy() + expect(navigation.router.replace).toHaveBeenCalledTimes(3) + }) + + it("waits for current history and navigation qualification before consuming", () => { + navigation.search = "seq=39" + const view = render(createElement(Context, { historyAllowed: false })) + expect(screen.getByText("closed")).toBeTruthy() + view.rerender(createElement(Context, { navigationAllowed: false })) + expect(navigation.router.replace).not.toHaveBeenCalled() + view.rerender(createElement(Context)) + expect(screen.getByText("target 39")).toBeTruthy() + expect(navigation.router.replace).toHaveBeenCalledOnce() + }) + + it.each(["history", "navigation"])("retires an open target on %s denial", (denial) => { + navigation.search = "seq=39" + const view = render(createElement(Context)) + navigation.search = "" + view.rerender(createElement(Context, { + historyAllowed: denial !== "history", + navigationAllowed: denial !== "navigation", + })) + expect(screen.getByText("closed")).toBeTruthy() + view.rerender(createElement(Context)) + expect(screen.getByText("closed")).toBeTruthy() + expect(navigation.router.replace).toHaveBeenCalledOnce() + }) + + it("handles history URL changes only for the current keyed DM owner", () => { + const view = render(createElement(Context, { key: "viewer1/dm1" })) + navigation.search = "seq=39" + view.rerender(createElement(Context, { key: "viewer1/dm1" })) + expect(screen.getByText("target 39")).toBeTruthy() + navigation.search = "" + view.rerender(createElement(Context, { key: "viewer1/dm1" })) + fireEvent.click(screen.getByText("Close")) + navigation.search = "seq=105" + view.rerender(createElement(Context, { key: "viewer1/dm1" })) + expect(screen.getByText("target 105")).toBeTruthy() + + navigation.pathname = "/c/me/dm2" + view.rerender(createElement(Context, { key: "viewer2/dm1" })) + expect(screen.getByText("closed")).toBeTruthy() + expect(navigation.router.replace).toHaveBeenCalledTimes(2) + navigation.search = "seq=7" + view.rerender(createElement(Context, { key: "viewer2/dm2", dmId: "dm2" })) + expect(screen.getByText("target 7")).toBeTruthy() + expect(navigation.router.replace).toHaveBeenLastCalledWith("/c/me/dm2", { scroll: false }) + }) + + it.each(["", "0", "-1", "1.5", "nope", "Infinity"])("ignores invalid seq %s", (seq) => { + navigation.search = `seq=${seq}` + render(createElement(Context)) + expect(screen.getByText("closed")).toBeTruthy() + expect(navigation.router.replace).not.toHaveBeenCalled() + }) +}) diff --git a/src/web/src/components/community/channels/use-dm-seq-context.ts b/src/web/src/components/community/channels/use-dm-seq-context.ts new file mode 100644 index 000000000..c41534a1b --- /dev/null +++ b/src/web/src/components/community/channels/use-dm-seq-context.ts @@ -0,0 +1,36 @@ +"use client" + +import { useEffect } from "react" +import { usePathname, useRouter, useSearchParams } from "next/navigation" +import { removeCommunityParam } from "@/lib/community/community-route" + +export function useDmSeqContext({ + dmId, + historyAllowed, + navigationAllowed, + setContextSeq, +}: { + dmId: string + historyAllowed: boolean + navigationAllowed: boolean + setContextSeq: (seq: number | null) => void +}) { + const pathname = usePathname() + const router = useRouter() + const searchParams = useSearchParams() + const seqParam = searchParams.get("seq") + const search = searchParams.toString() + + useEffect(() => { + if (!historyAllowed || !navigationAllowed) { + setContextSeq(null) + return + } + if (pathname !== `/c/me/${dmId}` || !seqParam) return + const seq = Number(seqParam) + if (!Number.isSafeInteger(seq) || seq < 1) return + setContextSeq(seq) + const href = `${pathname}${search ? `?${search}` : ""}` + router.replace(removeCommunityParam(href, "seq"), { scroll: false }) + }, [dmId, historyAllowed, navigationAllowed, pathname, router, search, seqParam, setContextSeq]) +} diff --git a/src/web/src/components/community/messages/initial-position-transition.dom.test.ts b/src/web/src/components/community/messages/initial-position-transition.dom.test.ts index e83585585..38b86eee2 100644 --- a/src/web/src/components/community/messages/initial-position-transition.dom.test.ts +++ b/src/web/src/components/community/messages/initial-position-transition.dom.test.ts @@ -3,9 +3,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { act, render } from "@/test/react-dom-harness" import { INITIAL_POSITION_CROSSFADE_MS, - INITIAL_POSITION_TIMEOUT_MS, useInitialPositionTransition, } from "./initial-position-transition" +import { INITIAL_POSITION_TIMEOUT_MS } from "@/hooks/community/use-scroll-anchor" type Input = { firstWindowReady: boolean @@ -121,8 +121,10 @@ describe("useInitialPositionTransition", () => { expect(latest).toMatchObject({ phase: "revealed" }) }) - it("cleans the timeout timer when a keyed mount leaves", () => { + it("has no independent position timer and cleans the crossfade when a keyed mount leaves", () => { const renderer = render(React.createElement(Probe, pending())) + expect(vi.getTimerCount()).toBe(0) + renderer.rerender(React.createElement(Probe, { ...pending(), positionSettled: true })) expect(vi.getTimerCount()).toBe(1) renderer.unmount() expect(vi.getTimerCount()).toBe(0) diff --git a/src/web/src/components/community/messages/initial-position-transition.ts b/src/web/src/components/community/messages/initial-position-transition.ts index cfa7aee79..b45cf0b5c 100644 --- a/src/web/src/components/community/messages/initial-position-transition.ts +++ b/src/web/src/components/community/messages/initial-position-transition.ts @@ -5,7 +5,6 @@ import { useAtom, useCreateAtom } from "@tanstack/react-store"; import { useEffect, useLayoutEffect, useRef } from "react" export const INITIAL_POSITION_CROSSFADE_MS = 300 -export const INITIAL_POSITION_TIMEOUT_MS = 2_000 export type InitialPositionPhase = | "skeleton" @@ -27,12 +26,10 @@ export function useInitialPositionTransition({ !firstWindowReady ? "skeleton" : initiallyRevealed ? "revealed" : "positioning" ), [firstWindowReady, initiallyRevealed]))) const revealedRef = useRef(initiallyRevealed) - const startedAtRef = useRef(null) useLayoutEffect(() => { if (revealedRef.current) return if (!firstWindowReady) { - startedAtRef.current = null setPhase("skeleton") return } @@ -46,7 +43,6 @@ export function useInitialPositionTransition({ setPhase("revealing") return } - if (startedAtRef.current === null) startedAtRef.current = Date.now() if (phase === "skeleton") setPhase("positioning") }, [authoritativeEmpty, firstWindowReady, phase, positionSettled, setPhase]) @@ -58,22 +54,7 @@ export function useInitialPositionTransition({ ) return () => window.clearTimeout(crossfadeTimer) } - if (revealedRef.current || !firstWindowReady || authoritativeEmpty) return - - const startedAt = startedAtRef.current ?? Date.now() - startedAtRef.current = startedAt - const timeoutRemaining = Math.max( - 0, - INITIAL_POSITION_TIMEOUT_MS - (Date.now() - startedAt), - ) - const timeoutTimer = window.setTimeout(() => { - if (revealedRef.current || !positionSettled) return - revealedRef.current = true - setPhase("revealing") - }, timeoutRemaining) - - return () => window.clearTimeout(timeoutTimer) - }, [authoritativeEmpty, firstWindowReady, phase, positionSettled, setPhase]) + }, [phase, setPhase]) // The first renderable window must mount its real DOM in the same commit // that clears loading. Waiting for the layout effect above to persist the @@ -81,7 +62,7 @@ export function useInitialPositionTransition({ // controller's one-shot hero measurement effect would observe no node and // initial anchoring would never arm. This projection changes presentation // only; settlement still comes exclusively from the action-owned callback. - const renderedPhase = phase === "skeleton" && firstWindowReady + const renderedPhase = !firstWindowReady ? "skeleton" : phase === "skeleton" ? authoritativeEmpty || positionSettled ? "revealed" : "positioning" : phase diff --git a/src/web/src/components/community/messages/message-channel-controller-state.dom.test.ts b/src/web/src/components/community/messages/message-channel-controller-state.dom.test.ts index b5dd12036..13a4aa07f 100644 --- a/src/web/src/components/community/messages/message-channel-controller-state.dom.test.ts +++ b/src/web/src/components/community/messages/message-channel-controller-state.dom.test.ts @@ -363,6 +363,56 @@ describe("useMessageChannelController", () => { ) }) + it("gives a split route URL seq only to the child while preserving parent local jumps", () => { + mocks.pathname = "/c/channels/server_1/child" + mocks.seq = "7" + const controllers = new Map() + function Pane({ channelId }: { channelId: string }) { + const controller = useMessageChannelController(props({ channelId })) + React.useLayoutEffect(() => { controllers.set(channelId, controller) }, [channelId, controller]) + return null + } + const panes = React.createElement(React.Fragment, null, + React.createElement(Pane, { channelId: "parent" }), + React.createElement(Pane, { channelId: "child" }), + ) + const view = rtlRender(panes) + expect(controllers.get("parent")!.contextTarget).toBeNull() + expect(controllers.get("child")!.contextTarget).toMatchObject({ channelId: "child", seq: 7 }) + expect(mocks.router.replace).toHaveBeenCalledOnce() + expect(mocks.router.replace).toHaveBeenCalledWith("/c/channels/server_1/child?keep=1", { scroll: false }) + mocks.seq = null + view.rerender(panes) + act(() => controllers.get("child")!.setContextTarget(null)) + act(() => controllers.get("parent")!.jumpToSeq(99)) + expect(controllers.get("parent")!.contextTarget).toMatchObject({ channelId: "parent", seq: 99 }) + expect(controllers.get("child")!.contextTarget).toBeNull() + mocks.seq = "7" + view.rerender(React.createElement(React.Fragment, null, + React.createElement(Pane, { channelId: "parent" }), + React.createElement(Pane, { channelId: "child" }), + )) + expect(controllers.get("parent")!.contextTarget).toMatchObject({ channelId: "parent", seq: 99 }) + expect(controllers.get("child")!.contextTarget).toMatchObject({ channelId: "child", seq: 7 }) + expect(mocks.router.replace).toHaveBeenCalledTimes(2) + }) + + it("renews a loaded URL target after Close and scroll consumption without a remount", () => { + mocks.seq = "1" + const value = props({ anchorMessageId: null }) + const view = rtlRender(React.createElement(Probe, { value })) + expect(latest.contextTarget).toMatchObject({ channelId: "channel_1", seq: 1 }) + expect(latest.scrollTargetId).toBe("m1") + mocks.seq = null + view.rerender(React.createElement(Probe, { value })) + act(() => { latest.setContextTarget(null); latest.consumeScrollTarget("m1") }) + expect(latest.scrollTargetId).toBeNull() + mocks.seq = "1" + view.rerender(React.createElement(Probe, { value })) + expect(latest.scrollTargetId).toBe("m1") + expect(latest.contextTarget).toMatchObject({ channelId: "channel_1", seq: 1 }) + }) + it("clears missing anchors only on authoritative error and consumes only the matching target", () => { const missingFeed = { ...feed, messages: [], isError: false } let renderer: ReturnType diff --git a/src/web/src/components/community/messages/message-channel-controller-state.ts b/src/web/src/components/community/messages/message-channel-controller-state.ts index 0f06551aa..eb7c3bcba 100644 --- a/src/web/src/components/community/messages/message-channel-controller-state.ts +++ b/src/web/src/components/community/messages/message-channel-controller-state.ts @@ -45,7 +45,7 @@ import { acceptChannelMessage, runAcceptedMessageIntent, } from "./message-channel-controller-send" -import { removeCommunityParam } from "@/lib/community/community-route" +import { channelHref, removeCommunityParam } from "@/lib/community/community-route" import type { MessageChannelControllerProps, MessageChannelControllerValue, @@ -56,6 +56,7 @@ import type { export function useMessageChannelController({ channelId, serverId, + serverParam, channelName, forumParentChannelId, viewer, @@ -219,13 +220,15 @@ export function useMessageChannelController({ const seqParam = searchParams.get("seq") const searchParamsString = searchParams.toString() useEffect(() => { - if (!seqParam) return + if (pathname !== channelHref(serverParam, channelId) || !seqParam) return const seq = Number(seqParam) - if (!Number.isFinite(seq)) return + if (!Number.isSafeInteger(seq) || seq < 1) return + const message = actionContext.get().messageIds.map(getMessage).find((item) => item?.seq === seq) + if (message) setScrollTargetId(message.id) setContextTarget({ serverId, channelId, label: channelName, seq }) const href = `${pathname}${searchParamsString ? `?${searchParamsString}` : ""}` router.replace(removeCommunityParam(href, "seq"), { scroll: false }) - }, [seqParam, serverId, channelId, channelName, pathname, router, searchParamsString, setContextTarget]) + }, [seqParam, serverId, serverParam, channelId, channelName, pathname, router, searchParamsString, actionContext, getMessage, setContextTarget, setScrollTargetId]) const messageScope = useMemo( () => ({ kind: "channel" as const, id: channelId, serverId }), diff --git a/src/web/src/components/community/messages/message-list-controller.dom.test.ts b/src/web/src/components/community/messages/message-list-controller.dom.test.ts index 571b1f16d..1eddb8959 100644 --- a/src/web/src/components/community/messages/message-list-controller.dom.test.ts +++ b/src/web/src/components/community/messages/message-list-controller.dom.test.ts @@ -21,6 +21,7 @@ const mocks = vi.hoisted(() => ({ virtualizer: { scrollToOffset: vi.fn(), containerRef: { current: null } }, jumpToIndex: vi.fn(), scrollToBottom: vi.fn(), + requestPresentPosition: vi.fn(), onImageLoad: vi.fn(), captureOlderPageAnchor: vi.fn(), captureNewerPageAnchor: vi.fn(), @@ -32,7 +33,16 @@ vi.mock("@/hooks/use-mobile", () => ({ vi.mock("@/hooks/community/use-scroll-anchor", () => ({ resolveMessageRailTailPaddingEnd: (breakpoint: string) => breakpoint === "mobile" ? 40 : 48, - useScrollAnchor: (input: unknown) => { + useScrollAnchor: (input: { scrollToMessageId?: string | null; items: Array<{ kind: string; m?: { id: string } }>; onScrollTargetPositioned: (id: string) => void; onInitialPositionSettled: () => void }) => { + const consumed = React.useRef(null) + const loaded = input.items.some((item) => item.m?.id === input.scrollToMessageId) + React.useLayoutEffect(() => { + if (!input.scrollToMessageId) consumed.current = null + if (input.scrollToMessageId && consumed.current !== input.scrollToMessageId && input.items.some((item) => item.m?.id === input.scrollToMessageId)) { + consumed.current = input.scrollToMessageId + input.onScrollTargetPositioned(input.scrollToMessageId) + } + }, [input, loaded]) mocks.hookOrder.push("anchor") mocks.scrollInputs.push(input) return { @@ -40,8 +50,9 @@ vi.mock("@/hooks/community/use-scroll-anchor", () => ({ virtualizer: mocks.virtualizer, belowCount: 2, scrollToBottom: mocks.scrollToBottom, + requestPresentPosition: mocks.requestPresentPosition, jumpTo: mocks.jumpToIndex, - onImageLoad: mocks.onImageLoad, + readPositionReady: false, captureOlderPageAnchor: mocks.captureOlderPageAnchor, isOlderPageAnchorSettling: false, captureNewerPageAnchor: mocks.captureNewerPageAnchor, @@ -80,7 +91,6 @@ function props(overrides: Partial = {}): ResolvedMessa } let latest: MessageListController -let currentHeroNode: HTMLDivElement let currentScrollNode: HTMLDivElement function assignRef(ref: { current: T | null }, value: T | null) { ref.current = value @@ -93,9 +103,6 @@ function Probe({ attachHero?: boolean }) { const controller = useMessageListController(value) - const attachHeroNode = React.useCallback((node: HTMLDivElement | null) => { - assignRef(controller.heroRef, node ? currentHeroNode : null) - }, [controller.heroRef]) const attachScrollNode = React.useCallback((node: HTMLDivElement | null) => { assignRef(controller.scrollRef, node ? currentScrollNode : null) }, [controller.scrollRef]) @@ -103,7 +110,7 @@ function Probe({ return React.createElement( React.Fragment, null, - attachHero ? React.createElement("div", { id: "hero", ref: attachHeroNode }) : null, + attachHero ? React.createElement("div", { id: "hero" }) : null, React.createElement("div", { id: "scroll", ref: attachScrollNode }), ) } @@ -116,7 +123,6 @@ describe("useMessageListController", () => { let cancelFrame: ReturnType let visibleMessageIds: string[] const disconnect = vi.fn() - const heroNode = { offsetHeight: 0 } const scrollNode = { scrollTop: 0, scrollHeight: 3206, @@ -131,7 +137,6 @@ describe("useMessageListController", () => { beforeEach(() => { mocks.breakpoint = "desktop" - currentHeroNode = heroNode as HTMLDivElement currentScrollNode = scrollNode as unknown as HTMLDivElement resizeCallback = null disconnect.mockClear() @@ -148,7 +153,6 @@ describe("useMessageListController", () => { visibleMessageIds = ["m1"] scrollNode.scrollTop = 0 scrollNode.clientHeight = 692 - heroNode.offsetHeight = 0 requestFrame = vi.fn((callback: FrameRequestCallback) => { const id = ++nextFrameId frameCallbacks.set(id, callback) @@ -200,10 +204,12 @@ describe("useMessageListController", () => { isFetchingNewer: undefined, presentVersion: undefined, viewerUserId: undefined, - heroHeight: 0, - heroMeasured: false, + hasMoreOlder: undefined, tailPaddingEnd: 48, onInitialPositionSettled: expect.any(Function), + scrollToMessageId: undefined, + onScrollTargetCancelled: undefined, + onScrollTargetPositioned: expect.any(Function), }) expect(mocks.sentinelInputs.slice(0, 2)).toEqual([ { @@ -229,7 +235,7 @@ describe("useMessageListController", () => { expect(latest.virtualizer).toBe(mocks.virtualizer) expect(latest.topSentinelRef).toBe(mocks.sentinelRefs.at(-2)) expect(latest.bottomSentinelRef).toBe(mocks.sentinelRefs.at(-1)) - expect(latest.onImageLoad).toBe(mocks.onImageLoad) + expect(latest.readPositionReady).toBe(false) expect(latest.pillCount).toBe(2) expect(latest.pillMode).toBe("scroll") expect(latest.pillOnClick).toBe(mocks.scrollToBottom) @@ -340,10 +346,12 @@ describe("useMessageListController", () => { isFetchingNewer: true, presentVersion: 7, viewerUserId: "viewer_1", - heroHeight: 0, - heroMeasured: true, + hasMoreOlder: true, tailPaddingEnd: 48, onInitialPositionSettled: expect.any(Function), + scrollToMessageId: undefined, + onScrollTargetCancelled: undefined, + onScrollTargetPositioned: expect.any(Function), }) expect(mocks.sentinelInputs.slice(-2)).toEqual([ { @@ -367,49 +375,26 @@ describe("useMessageListController", () => { ]) expect(latest.pillCount).toBe(9) expect(latest.pillMode).toBe("jump") - expect(latest.pillOnClick).toBe(jumpToPresent) + expect(latest.pillOnClick).toBeTypeOf("function") act(() => latest.pillOnClick()) expect(jumpToPresent).toHaveBeenCalledOnce() }) - it("publishes scroll roots cleanup-first and measures zero-height heroes immediately", () => { - const first = vi.fn() - const second = vi.fn() - let renderer: ReturnType - act(() => { - renderer = rtlRender( - React.createElement(Probe, { value: props({ onScrollRoot: first }) }) - ) - }) - expect((mocks.scrollInputs.at(-1) as { heroHeight: number; heroMeasured: boolean })) - .toEqual(expect.objectContaining({ heroHeight: 0, heroMeasured: true })) + it("publishes scroll roots cleanup-first without a hero measurement prerequisite", () => { + const first = vi.fn(), second = vi.fn() + const renderer = rtlRender(React.createElement(Probe, { value: props({ onScrollRoot: first }), attachHero: false })) expect(first).toHaveBeenCalledWith(scrollNode) - - act(() => { - renderer!.rerender(React.createElement(Probe, { value: props({ onScrollRoot: second }) })) - }) + expect(mocks.scrollInputs.at(-1)).not.toHaveProperty("heroHeight") + expect(mocks.scrollInputs.at(-1)).not.toHaveProperty("heroMeasured") + renderer.rerender(React.createElement(Probe, { value: props({ onScrollRoot: second }), attachHero: false })) expect(first.mock.calls.at(-1)).toEqual([null]) expect(second).toHaveBeenCalledWith(scrollNode) expect(first.mock.invocationCallOrder.at(-1)!).toBeLessThan(second.mock.invocationCallOrder[0]) - - act(() => resizeCallback?.([{ borderBoxSize: [{ blockSize: 42 }] }])) - expect((mocks.scrollInputs.at(-1) as { heroHeight: number; heroMeasured: boolean })) - .toEqual(expect.objectContaining({ heroHeight: 42, heroMeasured: true })) - heroNode.offsetHeight = 17 - act(() => resizeCallback?.([{}])) - expect((mocks.scrollInputs.at(-1) as { heroHeight: number; heroMeasured: boolean })) - .toEqual(expect.objectContaining({ heroHeight: 17, heroMeasured: true })) - act(() => { - renderer!.rerender(React.createElement(Probe, { - value: props({ loading: true, messages: [], onScrollRoot: second, hero: "changed" }), - })) - }) - expect((mocks.scrollInputs.at(-1) as { heroMeasured: boolean }).heroMeasured).toBe(true) - act(() => renderer!.unmount()) - expect(disconnect).toHaveBeenCalled() + renderer.unmount() + expect(second.mock.calls.at(-1)).toEqual([null]) }) - it("combines initial readiness with loaded-target readiness and preserves state across channel-only changes", () => { + it("delegates explicit-target readiness to the scroll owner and preserves state across channel-only changes", () => { const consumed = vi.fn() let renderer: ReturnType act(() => { @@ -417,7 +402,7 @@ describe("useMessageListController", () => { value: props({ scrollToMessageId: "m2", onScrollTargetConsumed: consumed }), })) }) - expect((mocks.scrollInputs.at(-1) as { initialScrollReady: boolean }).initialScrollReady).toBe(false) + expect((mocks.scrollInputs.at(-1) as { initialScrollReady: boolean }).initialScrollReady).toBe(true) expect(latest.initialPosition.phase).toBe("positioning") act(() => { renderer!.rerender(React.createElement(Probe, { @@ -434,11 +419,9 @@ describe("useMessageListController", () => { onInitialPositionSettled: () => void }).onInitialPositionSettled act(() => settleAnchor()) - expect(latest.initialPosition.phase).toBe("positioning") + expect(latest.initialPosition.phase).toBe("revealing") visibleMessageIds = ["m2"] runNextFrame() - expect(latest.initialPosition.phase).toBe("positioning") - runNextFrame() expect(latest.initialPosition.phase).toBe("revealing") act(() => vi.advanceTimersByTime(INITIAL_POSITION_CROSSFADE_MS)) expect(latest.initialPosition.phase).toBe("revealed") @@ -586,7 +569,7 @@ describe("useMessageListController", () => { expect(source).not.toContain("[data-selection=\"active\"]") }) - it("consumes a loaded target after zero-height measurement and clears highlight after visibility", () => { + it("uses owner completion and clears highlight after actual visibility", () => { const consumed = vi.fn() let renderer: ReturnType act(() => { @@ -596,7 +579,7 @@ describe("useMessageListController", () => { }) ) }) - expect(mocks.jumpToIndex).toHaveBeenCalledWith("m1", "auto") + expect(mocks.jumpToIndex).not.toHaveBeenCalled() expect(consumed).toHaveBeenCalledWith("m1") expect(latest.jumped).toBe("m1") runNextFrame() @@ -607,7 +590,7 @@ describe("useMessageListController", () => { act(() => renderer!.unmount()) }) - it("does not consume a loaded target until the hero ref has been measured", () => { + it("delegates a loaded target to the owner even when its hero is virtualized away", () => { const consumed = vi.fn() let renderer: ReturnType act(() => { @@ -619,7 +602,7 @@ describe("useMessageListController", () => { ) }) expect(mocks.jumpToIndex).not.toHaveBeenCalled() - expect(consumed).not.toHaveBeenCalled() + expect(consumed).toHaveBeenCalledWith("m1") act(() => { renderer!.rerender(React.createElement(Probe, { @@ -631,7 +614,7 @@ describe("useMessageListController", () => { }), })) }) - expect(mocks.jumpToIndex).toHaveBeenCalledWith("m1", "auto") + expect(mocks.jumpToIndex).not.toHaveBeenCalled() expect(consumed).toHaveBeenCalledWith("m1") act(() => renderer!.unmount()) }) @@ -644,8 +627,8 @@ describe("useMessageListController", () => { })) }) const pendingFrames = [...frameCallbacks.keys()] - expect(pendingFrames).toHaveLength(2) - expect(vi.getTimerCount()).toBe(1) + expect(pendingFrames).toHaveLength(1) + expect(vi.getTimerCount()).toBe(0) act(() => renderer!.unmount()) for (const frame of pendingFrames) expect(cancelFrame).toHaveBeenCalledWith(frame) diff --git a/src/web/src/components/community/messages/message-list-controller.ts b/src/web/src/components/community/messages/message-list-controller.ts index 2b4746513..735133d24 100644 --- a/src/web/src/components/community/messages/message-list-controller.ts +++ b/src/web/src/components/community/messages/message-list-controller.ts @@ -1,7 +1,7 @@ "use client" import { useAtom, useCreateAtom } from "@tanstack/react-store"; -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react" +import { useCallback, useEffect, useMemo, useRef } from "react" import type { RenderMsg } from "@/lib/community/models/message" import { flattenMessageItems } from "@/lib/community/message-list-items" import { @@ -16,6 +16,7 @@ import type { ResolvedMessageListProps } from "./message-list-types" export function useMessageListController({ messages, loading, + initialLoadError, newDividerBefore, scrollToMessageId, initialScrollReady, @@ -29,25 +30,19 @@ export function useMessageListController({ presentVersion, unreadCount, viewerUserId, - hero, onScrollRoot, onScrollTargetConsumed, }: ResolvedMessageListProps) { const breakpoint = useBreakpoint() const tailPaddingEnd = resolveMessageRailTailPaddingEnd(breakpoint) + const [jumpRevision, setJumpRevision] = useAtom(useCreateAtom(0)) const [jumped, setJumped] = useAtom(useCreateAtom(null)) const [anchorPositionSettled, setAnchorPositionSettled] = useAtom(useCreateAtom(false)) - const [targetPositionSettled, setTargetPositionSettled] = useAtom(useCreateAtom(!scrollToMessageId)) - const targetSettleFrameRef = useRef(null) - const consumedScrollTargetRef = useRef(null) const items = useMemo( () => flattenMessageItems(messages, newDividerBefore, !!hasMore), [messages, newDividerBefore, hasMore], ) - const scrollTargetLoaded = !!scrollToMessageId - && messages.some((message) => message.id === scrollToMessageId) - const scrollAnchorReady = scrollToMessageId ? scrollTargetLoaded : initialScrollReady const [selectMode, setSelectMode] = useAtom(useCreateAtom(false)) const [selectedIds, setSelectedIds] = useAtom(useCreateAtom>(useMemo>(() => new Set(), []))) @@ -90,33 +85,17 @@ export function useMessageListController({ }) }, [items, selectedIds]) - const heroRef = useRef(null) - const [heroHeight, setHeroHeight] = useAtom(useCreateAtom(0)) - const [heroMeasured, setHeroMeasured] = useAtom(useCreateAtom(false)) - const isLoading = !!loading && messages.length === 0 - const authoritativeEmpty = !loading && messages.length === 0 + const isLoading = (!!loading || !!initialLoadError) && messages.length === 0 + const authoritativeEmpty = !loading && !initialLoadError && messages.length === 0 const settleAnchorPosition = useCallback(() => setAnchorPositionSettled(true), [setAnchorPositionSettled]) - useEffect(() => { - const element = heroRef.current - if (!element) return - const observer = new ResizeObserver((entries) => { - const height = entries[0]?.borderBoxSize?.[0]?.blockSize ?? element.offsetHeight - setHeroHeight(height) - setHeroMeasured(true) - }) - observer.observe(element) - setHeroHeight(element.offsetHeight) - setHeroMeasured(true) - return () => observer.disconnect() - }, [isLoading, hasMore, hero, setHeroHeight, setHeroMeasured]) - const { scrollRef, virtualizer, belowCount, scrollToBottom, + requestPresentPosition, jumpTo: jumpToIndex, - onImageLoad, + readPositionReady, captureOlderPageAnchor, isOlderPageAnchorSettling, captureNewerPageAnchor, @@ -124,21 +103,23 @@ export function useMessageListController({ } = useScrollAnchor({ items, newDividerBefore, - initialScrollReady: scrollAnchorReady, + initialScrollReady, + scrollToMessageId, + onScrollTargetCancelled: onScrollTargetConsumed, + onScrollTargetPositioned: (id) => { setJumped(id); setJumpRevision((value) => value + 1); onScrollTargetConsumed?.(id) }, hasMoreNewer, isFetchingOlder, isFetchingNewer, presentVersion, viewerUserId, - heroHeight, - heroMeasured, + hasMoreOlder: hasMore, tailPaddingEnd, onInitialPositionSettled: settleAnchorPosition, }) const initialPosition = useInitialPositionTransition({ - firstWindowReady: !isLoading, + firstWindowReady: !isLoading && (messages.length > 0 || !initialLoadError), authoritativeEmpty, - positionSettled: anchorPositionSettled && targetPositionSettled, + positionSettled: anchorPositionSettled, }) useEffect(() => { @@ -169,22 +150,29 @@ export function useMessageListController({ const jumpClearTimerRef = useRef(null) const jumpVisibilityFrameRef = useRef(null) const jumpTo = useCallback((id: string, behavior: ScrollBehavior = "smooth") => { - if (jumpClearTimerRef.current !== null) clearTimeout(jumpClearTimerRef.current) - if (jumpVisibilityFrameRef.current !== null) { - window.cancelAnimationFrame(jumpVisibilityFrameRef.current) - } setJumped(id) + setJumpRevision((value) => value + 1) jumpToIndex(id, behavior) + }, [jumpToIndex, setJumped, setJumpRevision]) + useEffect(() => { + if (!jumped) return + const id = jumped + let active = true + if (jumpClearTimerRef.current !== null) clearTimeout(jumpClearTimerRef.current) + if (jumpVisibilityFrameRef.current !== null) window.cancelAnimationFrame(jumpVisibilityFrameRef.current) let attempts = 0 const armClear = () => { + if (!active) return jumpVisibilityFrameRef.current = null const timeout = window.setTimeout(() => { + if (!active) return setJumped((value) => (value === id ? null : value)) if (jumpClearTimerRef.current === timeout) jumpClearTimerRef.current = null }, 1600) jumpClearTimerRef.current = timeout } const waitUntilVisible = () => { + if (!active) return const root = scrollRef.current const row = root ? Array.from(root.querySelectorAll("[data-msg-id]")) @@ -206,48 +194,20 @@ export function useMessageListController({ jumpVisibilityFrameRef.current = window.requestAnimationFrame(waitUntilVisible) } jumpVisibilityFrameRef.current = window.requestAnimationFrame(waitUntilVisible) - }, [jumpToIndex, scrollRef, setJumped]) - useEffect(() => () => { - if (jumpClearTimerRef.current !== null) clearTimeout(jumpClearTimerRef.current) - if (jumpVisibilityFrameRef.current !== null) { - window.cancelAnimationFrame(jumpVisibilityFrameRef.current) - } - }, []) - - useLayoutEffect(() => { - if (!scrollToMessageId) return - setTargetPositionSettled(false) - }, [scrollToMessageId, setTargetPositionSettled]) - - useEffect(() => { - if (!scrollToMessageId) { - consumedScrollTargetRef.current = null - return - } - if (consumedScrollTargetRef.current === scrollToMessageId) return - if (!scrollTargetLoaded || !heroMeasured) return - consumedScrollTargetRef.current = scrollToMessageId - jumpTo(scrollToMessageId, "auto") - onScrollTargetConsumed?.(scrollToMessageId) - if (targetSettleFrameRef.current !== null) { - window.cancelAnimationFrame(targetSettleFrameRef.current) - } - targetSettleFrameRef.current = window.requestAnimationFrame(() => { - targetSettleFrameRef.current = null - setTargetPositionSettled(true) - }) - }, [scrollToMessageId, scrollTargetLoaded, heroMeasured, jumpTo, onScrollTargetConsumed, setTargetPositionSettled]) - useLayoutEffect(() => () => { - if (targetSettleFrameRef.current !== null) { - window.cancelAnimationFrame(targetSettleFrameRef.current) + return () => { + active = false + if (jumpClearTimerRef.current !== null) clearTimeout(jumpClearTimerRef.current) + if (jumpVisibilityFrameRef.current !== null) window.cancelAnimationFrame(jumpVisibilityFrameRef.current) } - }, []) + }, [jumped, jumpRevision, scrollRef, setJumped]) const jumpMode = !!hasMoreNewer const pillCount = jumpMode ? ((unreadCount ?? belowCount) || 0) : belowCount - const pillOnClick = jumpMode - ? (onJumpToPresent ?? scrollToBottom) - : scrollToBottom + const jumpToPresent = useCallback(() => { + requestPresentPosition() + onJumpToPresent?.() + }, [onJumpToPresent, requestPresentPosition]) + const pillOnClick = jumpMode && onJumpToPresent ? jumpToPresent : scrollToBottom const closeShare = useCallback(() => { setShareOpen(false) @@ -268,12 +228,11 @@ export function useMessageListController({ closeShare, onEnterSelectId, onToggleSelectId, - heroRef, scrollRef, virtualizer, topSentinelRef, bottomSentinelRef, - onImageLoad, + readPositionReady, jumpTo, pillCount, pillMode: jumpMode ? "jump" as const : "scroll" as const, diff --git a/src/web/src/components/community/messages/message-list-facade.contract.test.ts b/src/web/src/components/community/messages/message-list-facade.contract.test.ts index 7fc3f49d1..adaa1e6a5 100644 --- a/src/web/src/components/community/messages/message-list-facade.contract.test.ts +++ b/src/web/src/components/community/messages/message-list-facade.contract.test.ts @@ -70,7 +70,7 @@ describe("MessageList facade contract", () => { expect(text).toContain("virtualizer={controller.virtualizer}") expect(text).toContain("itemKey={(item) => item.key}") expect(text).toContain( - "renderItem={(item) => renderMessageListRow(item, resolvedProps, controller)}", + "renderItem={(item, index) => renderMessageListRow(item, resolvedProps, controller, index)}", ) expect(text).not.toMatch(/ { beforeEach(() => vi.clearAllMocks()) - it("renders divider branches without mounting MessageRow", () => { - const date = render(renderMessageListRow( - { kind: "date-divider", key: "d", label: "Today" } as FlatItem, - props, - controller, - )) - const unread = render(renderMessageListRow( - { kind: "new-divider", key: "n", dateLabel: "Today" } as FlatItem, - props, - controller, - )) - expect(date.getByTestId("date-divider")).toHaveAttribute("data-label", "Today") - expect(unread.getByTestId("new-divider")).toHaveAttribute("data-date-label", "Today") - expect(mockedMessageRow).not.toHaveBeenCalled() + it("keeps merged New/date and edge decorations outside the actual body", () => { + const item: FlatItem = { kind: "message", key: "m1", dateLabel: "Today", newDivider: true, m: { id: "m1", type: "chat" } } + const view = render(renderMessageListRow(item, { ...props, hasMore: true, hasMoreNewer: true }, controller, 0)) + const body = view.container.querySelector('[data-msg-id="m1"]')! + expect(view.getByTestId("new-divider")).toHaveAttribute("data-date-label", "Today") + expect(body.querySelector('[data-testid="new-divider"]')).toBeNull() + expect(view.queryByTestId("date-divider")).toBeNull() + expect(view.container.firstElementChild).toHaveClass("flow-root") + expect(controller.topSentinelRef).toHaveBeenCalled() + expect(controller.bottomSentinelRef).toHaveBeenCalled() + expect(mockedMessageRow).toHaveBeenCalledOnce() }) it("projects every row identity/action prop and gates edit to the viewer", () => { @@ -94,6 +93,7 @@ describe("renderMessageListRow", () => { { kind: "message", key: "m1", m: message } as FlatItem, props, controller, + 0, )) expect(renderer.container.querySelector('[data-msg-id="m1"]')) .toHaveAttribute("data-testid", "community-message-m1") @@ -120,7 +120,6 @@ describe("renderMessageListRow", () => { onPreviewImage: callbacks.onPreviewImage, onPreviewAttachment: callbacks.onPreviewAttachment, resolveUserName: callbacks.resolveUserName, - onImageLoad: controller.onImageLoad, selectMode: true, selected: true, onToggleSelectId: controller.onToggleSelectId, @@ -131,7 +130,9 @@ describe("renderMessageListRow", () => { { kind: "message", key: "m1", m: { ...message, authorId: "peer_1" } } as FlatItem, props, controller, + 0, )) expect(mockedMessageRow.mock.calls.at(-1)?.[0].onEditId).toBeUndefined() + expect(mockedMessageRow.mock.calls.at(-1)?.[0].onImageLoad).toBeUndefined() }) }) diff --git a/src/web/src/components/community/messages/message-list-row.tsx b/src/web/src/components/community/messages/message-list-row.tsx index 227e8189f..229fd075a 100644 --- a/src/web/src/components/community/messages/message-list-row.tsx +++ b/src/web/src/components/community/messages/message-list-row.tsx @@ -1,20 +1,45 @@ import { DateDivider, NewDivider } from "../dividers" import { tid } from "@/lib/community/testids" +import { ChannelIcon } from "../channels/channel-icon" import { MessageRow } from "./message-row" import type { FlatItem } from "@/lib/community/message-list-items" import type { MessageListController } from "./message-list-controller" import type { ResolvedMessageListProps } from "./message-list-types" +export function renderMessageListHero(props: ResolvedMessageListProps) { + return props.hero ?? ( + <> +
+ +
+

{props.channel}

+

+ Beginning of the channel. Say hello, share what you're working on, or drop a link. +

+ + ) +} + export function renderMessageListRow( item: FlatItem, props: ResolvedMessageListProps, controller: MessageListController, + index: number, ) { return ( - <> - {item.kind === "date-divider" && } - {item.kind === "new-divider" && } - {item.kind === "message" && ( +
+ {index === 0 && ( +
+ {props.hasMore ? ( +
+ {props.isFetchingOlder ? "Loading older messages…" : ""} +
+ ) : renderMessageListHero(props)} +
+ )} + {item.newDivider + ? + : item.dateLabel && }
+ {index === controller.items.length - 1 && props.hasMoreNewer && ( +
+ {props.isFetchingNewer ? "Loading newer messages…" : ""} +
)} - +
) } diff --git a/src/web/src/components/community/messages/message-list-types.ts b/src/web/src/components/community/messages/message-list-types.ts index e274b77da..0f1ecbac6 100644 --- a/src/web/src/components/community/messages/message-list-types.ts +++ b/src/web/src/components/community/messages/message-list-types.ts @@ -6,6 +6,9 @@ export type MessageListProps = { channel: string messages: Msg[] loading?: boolean + initialLoadError?: Error | null + retryingInitialLoad?: boolean + onRetryInitialLoad?: () => void pinnedIds?: Set newDividerBefore?: string typingUsers?: string[] diff --git a/src/web/src/components/community/messages/message-list-view.dom.test.ts b/src/web/src/components/community/messages/message-list-view.dom.test.ts index 009697edc..044049e81 100644 --- a/src/web/src/components/community/messages/message-list-view.dom.test.ts +++ b/src/web/src/components/community/messages/message-list-view.dom.test.ts @@ -1,6 +1,6 @@ import React from "react" import { beforeEach, describe, expect, it, vi } from "vitest" -import { render } from "@/test/react-dom-harness" +import { fireEvent, render } from "@/test/react-dom-harness" import { MessageListSkeleton, renderMessageListView } from "./message-list-view" import { ComposerAccessoryRail } from "./composer-accessory-rail" import { MessageShareDialog } from "./message-share-dialog" @@ -80,12 +80,11 @@ function controller(overrides: Partial = {}): MessageList closeShare: vi.fn(), onEnterSelectId: vi.fn(), onToggleSelectId: vi.fn(), - heroRef: { current: null }, scrollRef: { current: null }, virtualizer: {} as MessageListController["virtualizer"], - topSentinelRef: { current: null }, - bottomSentinelRef: { current: null }, - onImageLoad: vi.fn(), + topSentinelRef: vi.fn(), + bottomSentinelRef: vi.fn(), + readPositionReady: true, jumpTo: vi.fn(), pillCount: 3, pillMode: "jump", @@ -112,7 +111,7 @@ describe("renderMessageListView", () => { expect(renderer.container.querySelectorAll("[data-message-typing-space]")).toHaveLength(0) expect(renderRows).toHaveBeenCalledOnce() expect(renderer.container.querySelectorAll("virtual-rows")).toHaveLength(1) - expect(renderer.container.querySelectorAll(".mb-6")).toHaveLength(1) + expect(renderer.container.querySelectorAll(".mb-6")).toHaveLength(0) }) it("keeps the same wrappers while true empty loading omits the interactive accessory rail", () => { @@ -323,66 +322,39 @@ describe("renderMessageListView", () => { footerSlot.remove() }) - it("keeps both sentinels and the direct rows callback in the exact loaded DOM positions", () => { - const state = controller({ - topSentinelRef: { current: null }, - bottomSentinelRef: { current: null }, - scrollRef: { current: null }, - heroRef: { current: null }, - }) - const renderRows = vi.fn(() => React.createElement("virtual-rows", { marker: "rows" })) - const renderer = render(renderMessageListView( - props({ - hasMore: true, - isFetchingOlder: true, - hasMoreNewer: true, - isFetchingNewer: true, - }), - state, - renderRows, - )) - const heroNode = renderer.container.querySelector(".mb-6") - const scrollNode = renderer.container.querySelector(".overflow-y-auto") - const topNode = renderer.container.querySelector(".flex.h-8") - const bottomNode = renderer.container.querySelector(".mt-6.flex.h-8") - expect(state.heroRef.current).toBe(heroNode) - expect(state.scrollRef.current).toBe(scrollNode) - expect(state.topSentinelRef.current).toBe(topNode) - expect(state.bottomSentinelRef.current).toBe(bottomNode) - expect(renderRows).toHaveBeenCalledOnce() - expect(renderer.container.querySelector('virtual-rows[marker="rows"]')).toBeInTheDocument() - expect(renderer.getByText("Loading older messages…")).toBeInTheDocument() - expect(renderer.getByText("Loading newer messages…")).toBeInTheDocument() - - const content = renderer.container.querySelector("[data-message-list-content]")! - expect(content).not.toHaveClass("pb-4", "sm:pb-6") - const elementChildren = Array.from(content.children) - expect(elementChildren).toHaveLength(3) - expect(elementChildren[0]).toHaveClass("mb-6") - expect(elementChildren[1]?.localName).toBe("virtual-rows") - expect(elementChildren[2]).toHaveClass("mt-6") - - renderer.rerender(renderMessageListView( - props({ - hasMore: true, - isFetchingOlder: false, - hasMoreNewer: true, - isFetchingNewer: false, - }), - state, - renderRows, - )) - expect(renderer.queryAllByText("Loading older messages…")).toHaveLength(0) - expect(renderer.queryAllByText("Loading newer messages…")).toHaveLength(0) - expect(renderer.container.querySelectorAll(".flex.h-8")).toHaveLength(2) - expect(renderer.container.querySelectorAll(".mt-6.flex.h-8")).toHaveLength(1) + it("keeps nonempty content at the native origin with edges delegated to rows", () => { + const state = controller({ readPositionReady: false }) + const renderRows = vi.fn(() => React.createElement("virtual-rows")) + const renderer = render(renderMessageListView(props({ hasMore: true, hasMoreNewer: true }), state, renderRows)) + const content = renderer.container.querySelector("[data-message-list-content]")! + expect(content.children).toHaveLength(1) + expect(content).not.toHaveClass("pt-8", "justify-end", "min-h-full") + expect(content).toHaveAttribute("data-read-position-ready", "false") + expect(renderer.queryByText(/Beginning of the channel/)).toBeNull() + expect(renderer.container.querySelectorAll(".h-8")).toHaveLength(0) + renderer.rerender(renderMessageListView(props({ messages: [] }), state, renderRows)) + expect(content).toHaveClass("pt-8", "justify-end", "min-h-full") + expect(renderer.getByText(/Beginning of the channel/)).toBeInTheDocument() + }) - renderer.rerender(renderMessageListView( - props({ hasMore: false, hasMoreNewer: false }), - state, - renderRows, - )) - expect(renderer.container.querySelectorAll(".flex.h-8")).toHaveLength(0) - expect(renderer.container.querySelectorAll(".mt-6.flex.h-8")).toHaveLength(0) + it("keeps the original scroller mounted through a cold error and retry, without rendering an empty hero", () => { + const retry = vi.fn() + const renderRows = vi.fn(() => React.createElement("virtual-rows")) + const state = controller({ initialPosition: initialPosition({ phase: "skeleton", showSkeleton: true, contentVisible: false, contentInteractive: false }) }) + const input = props({ messages: [], initialLoadError: new Error("timeout"), onRetryInitialLoad: retry }) + const view = render(renderMessageListView(input, state, renderRows)) + const scroller = view.container.querySelector('[data-testid="community-message-scroller"]') ?? view.container.querySelector('.thin-scrollbar') + fireEvent.click(view.getByRole("button", { name: "Retry" })) + expect(retry).toHaveBeenCalledOnce() + expect(renderRows).not.toHaveBeenCalled() + expect(view.queryByText(/Beginning of the channel/)).toBeNull() + view.rerender(renderMessageListView({ ...input, retryingInitialLoad: true }, state, renderRows)) + expect(view.getByRole("button", { name: "Retrying…" })).toBeDisabled() + expect(view.container.querySelector('.thin-scrollbar')).toBe(scroller) + view.rerender(renderMessageListView(props({ loading: false }), controller(), renderRows)) + expect(view.container.querySelector('.thin-scrollbar')).toBe(scroller) + expect(view.queryByRole("alert")).toBeNull() + expect(renderRows).toHaveBeenCalledOnce() }) + }) diff --git a/src/web/src/components/community/messages/message-list-view.tsx b/src/web/src/components/community/messages/message-list-view.tsx index 2ecc811a0..fbaf487c3 100644 --- a/src/web/src/components/community/messages/message-list-view.tsx +++ b/src/web/src/components/community/messages/message-list-view.tsx @@ -1,8 +1,9 @@ import type { ReactNode } from "react" import { createPortal } from "react-dom" +import { ConversationResolutionErrorFrame } from "../channels/conversation-resolution-error-frame" import { Skeleton } from "@/components/ui/skeleton" import { tid } from "@/lib/community/testids" -import { ChannelIcon } from "../channels/channel-icon" +import { renderMessageListHero } from "./message-list-row" import { ComposerAccessoryRail, MessageSelectionFooter } from "./composer-accessory-rail" import { MessageShareDialog } from "./message-share-dialog" import type { MessageListController } from "./message-list-controller" @@ -48,9 +49,10 @@ export function renderMessageListView(
- {controller.initialPosition.showSkeleton ? ( + {props.messages.length === 0 && props.initialLoadError ? ( + {})} /> + ) : controller.initialPosition.showSkeleton ? ( ) : ( <> -
- {props.hasMore ? ( -
- {props.isFetchingOlder ? "Loading older messages…" : ""} -
- ) : ( - props.hero ?? ( - <> -
- -
-

{props.channel}

-

- Beginning of the channel. Say hello, share what you're working on, or drop a link. -

- - ) - )} -
- - {renderRows()} - - {props.hasMoreNewer && ( -
- {props.isFetchingNewer ? "Loading newer messages…" : ""} -
- )} + {props.messages.length === 0 + ? <> +
{renderMessageListHero(props)}
+ diff --git a/src/web/src/components/community/messages/message-list.jump-target.dom.test.ts b/src/web/src/components/community/messages/message-list.jump-target.dom.test.ts index d2e9be346..5ec9fa469 100644 --- a/src/web/src/components/community/messages/message-list.jump-target.dom.test.ts +++ b/src/web/src/components/community/messages/message-list.jump-target.dom.test.ts @@ -2,156 +2,58 @@ import React from "react" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { MessageList } from "./message-list" import { act, render } from "@/test/react-dom-harness" +import { installMessageScrollFixture, restoreMessageScrollFixture, resize, runFrames } from "@/test/message-scroll-fixture" vi.mock("@/hooks/use-mobile", () => ({ useBreakpoint: () => "desktop" })) - -const mocks = vi.hoisted(() => { - const scrollToIndex = vi.fn() - const scrollToEnd = vi.fn() - const onScrollTargetConsumed = vi.fn() - return { - scrollToIndex, - scrollToEnd, - onScrollTargetConsumed, - virtualizer: { - options: { anchorTo: "end" }, - scrollToIndex, - scrollToEnd, - isAtEnd: () => true, - range: null, - }, - } -}) - -vi.mock("@tanstack/react-virtual", () => ({ - useVirtualizer: () => mocks.virtualizer, -})) -vi.mock("@/hooks/community/use-virtual-cursor-sentinel", () => ({ - useVirtualCursorSentinel: () => vi.fn(), -})) -vi.mock("./virtual-cursor-list", () => ({ - VirtualRows: ({ items, renderItem }: { - items: Array<{ key: string }> - renderItem: (item: { key: string }) => React.ReactNode - }) => React.createElement(React.Fragment, null, ...items.map((item) => renderItem(item))), -})) vi.mock("./message-row", () => ({ MessageRow: ({ m, highlighted }: { m: { id: string }; highlighted: boolean }) => - React.createElement("div", { - "data-msg-id": m.id, - "data-row-id": m.id, - "data-highlighted": highlighted, - }), + React.createElement("div", { "data-row-id": m.id, "data-highlighted": highlighted }), })) vi.mock("./message-share-dialog", () => ({ MessageShareDialog: () => null })) vi.mock("./typing-indicator", () => ({ TypingIndicator: () => null })) -const target = { - id: "m_target", - type: "chat" as const, - authorId: "user_2", - authorName: "Alice", - content: "Target", - createdAt: new Date(0).toISOString(), -} - -const unrelated = { - ...target, - id: "m_other", - content: "Other", -} - -const newer = { - ...target, - id: "m_newer", - content: "Newer", - createdAt: new Date(1).toISOString(), -} - -function view( - messages: typeof target[], - scrollToMessageId: string | null, - loading = false, - initialScrollReady = true, -) { - return React.createElement(MessageList, { - channel: "general", - messages, - loading, - initialScrollReady, - hasMoreNewer: true, - scrollToMessageId, - onScrollTargetConsumed: mocks.onScrollTargetConsumed, - onOpenThread: vi.fn(), - }) +const target = { id: "m_target", type: "chat" as const, authorId: "user_2", authorName: "Alice", content: "Target", createdAt: new Date(0).toISOString() } +const unrelated = { ...target, id: "m_other", content: "Other" } +const newer = { ...target, id: "m_newer", content: "Newer", createdAt: new Date(1).toISOString() } +const consumed = vi.fn() +function view(messages: typeof target[], scrollToMessageId: string | null) { + return React.createElement(MessageList, { channel: "general", messages, loading: false, initialScrollReady: false, hasMoreNewer: true, scrollToMessageId, onScrollTargetConsumed: consumed, onOpenThread: vi.fn() }) } - function highlighted(container: HTMLElement) { - return container.querySelector('[data-row-id="m_target"]') - ?.getAttribute("data-highlighted") === "true" + return container.querySelector('[data-row-id="m_target"]')?.getAttribute("data-highlighted") === "true" } -describe("MessageList pending jump target", () => { - beforeEach(() => { - vi.useFakeTimers() - vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { - callback(0) - return 1 - }) - vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => {}) - vi.stubGlobal("ResizeObserver", class { - observe() {} - disconnect() {} - }) - vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockReturnValue(48) - vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(800) - vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function () { - return (this as HTMLElement).dataset.msgId === "m_target" - ? DOMRect.fromRect({ y: 300, height: 100 }) - : DOMRect.fromRect({ y: 0, height: 800 }) - }) - mocks.scrollToIndex.mockClear() - mocks.scrollToEnd.mockClear() - mocks.onScrollTargetConsumed.mockClear() - }) - - afterEach(() => { - vi.unstubAllGlobals() - vi.restoreAllMocks() - vi.useRealTimers() - }) - - it("waits for the target row, jumps once, and resets after null", () => { - const renderer = render(view([unrelated], "m_target", false, false)) - expect(mocks.scrollToEnd).not.toHaveBeenCalled() - expect(mocks.scrollToIndex).not.toHaveBeenCalled() - expect(mocks.onScrollTargetConsumed).not.toHaveBeenCalled() - - renderer.rerender(view([unrelated, target], "m_target", false, false)) - expect(mocks.scrollToEnd).toHaveBeenCalledOnce() - expect(mocks.scrollToIndex).toHaveBeenCalledOnce() - expect(mocks.scrollToEnd.mock.invocationCallOrder[0]).toBeLessThan( - mocks.scrollToIndex.mock.invocationCallOrder[0], - ) - expect(mocks.scrollToIndex).toHaveBeenCalledWith(2, { align: "center", behavior: "auto" }) - expect(mocks.onScrollTargetConsumed).toHaveBeenCalledOnce() - expect(mocks.onScrollTargetConsumed).toHaveBeenCalledWith("m_target") - expect(highlighted(renderer.container)).toBe(true) - - renderer.rerender(view([unrelated, target, newer], "m_target", false, false)) - expect(mocks.scrollToIndex).toHaveBeenCalledOnce() - expect(mocks.onScrollTargetConsumed).toHaveBeenCalledOnce() - - act(() => vi.advanceTimersByTime(800)) - - renderer.rerender(view([unrelated, target, newer], null, false, false)) - renderer.rerender(view([unrelated, target, newer], "m_target", false, false)) - expect(mocks.scrollToIndex).toHaveBeenCalledTimes(2) - expect(mocks.onScrollTargetConsumed).toHaveBeenCalledTimes(2) - act(() => vi.advanceTimersByTime(800)) +describe("MessageList pending jump target with the installed native adapter", () => { + beforeEach(() => { installMessageScrollFixture(); consumed.mockClear() }) + afterEach(restoreMessageScrollFixture) + it("waits for the actual target body, consumes once, and resets after null", () => { + const renderer = render(view([unrelated], "m_target")) + resize() + expect(consumed).not.toHaveBeenCalled() + expect(renderer.container.querySelector('[data-message-list-content]')).toHaveAttribute("data-read-position-ready", "false") + renderer.rerender(view([unrelated, target], "m_target")) + resize() + expect(consumed).toHaveBeenCalledExactlyOnceWith("m_target") expect(highlighted(renderer.container)).toBe(true) - act(() => vi.advanceTimersByTime(799)) + expect(renderer.container.querySelector('[data-message-list-content]')).toHaveAttribute("data-read-position-ready", "true") + renderer.rerender(view([unrelated, target, newer], "m_target")) + resize() + expect(consumed).toHaveBeenCalledOnce() + renderer.rerender(view([unrelated, target, newer], null)) + renderer.rerender(view([unrelated, target, newer], "m_target")) + resize() + expect(consumed).toHaveBeenCalledTimes(2) expect(highlighted(renderer.container)).toBe(true) - act(() => vi.advanceTimersByTime(1)) + act(() => vi.advanceTimersByTime(1600)) + runFrames() expect(highlighted(renderer.container)).toBe(false) }) + it("withholds cached message rows after definitive access denial", () => { + const retry = vi.fn() + const renderer = render(React.createElement(MessageList, { channel: "general", messages: [target], loading: false, onOpenThread: vi.fn(), initialLoadError: Object.assign(new Error("Forbidden"), { status: 403 }), onRetryInitialLoad: retry })) + resize() + expect(renderer.getByRole("alert")).toBeInTheDocument() + expect(renderer.container.querySelector('[data-msg-id="m_target"]')).toBeNull() + expect(renderer.queryByText(/Beginning of the channel/)).toBeNull() + expect(renderer.getByRole("button", { name: "Retry" })).toBeInTheDocument() + }) }) diff --git a/src/web/src/components/community/messages/message-list.mount-identity.dom.test.ts b/src/web/src/components/community/messages/message-list.mount-identity.dom.test.ts index 30461bbf0..d7e236c8d 100644 --- a/src/web/src/components/community/messages/message-list.mount-identity.dom.test.ts +++ b/src/web/src/components/community/messages/message-list.mount-identity.dom.test.ts @@ -1,91 +1,51 @@ -import { afterEach, describe, it, expect, vi } from "vitest" +import { afterEach, beforeEach, describe, it, expect, vi } from "vitest" import React from "react" import { MessageList } from "./message-list" import { render } from "@/test/react-dom-harness" +import { installMessageScrollFixture, restoreMessageScrollFixture, resize, scrollFixture } from "@/test/message-scroll-fixture" +import { tid } from "@/lib/community/testids" vi.mock("@/hooks/use-mobile", () => ({ useBreakpoint: () => "desktop" })) +vi.mock("./message-row", () => ({ MessageRow: () => React.createElement("div") })) +vi.mock("@/components/ui/number-ticker", () => ({ NumberTicker: ({ value }: { value: number }) => React.createElement("span", null, value) })) -vi.mock("@/components/ui/number-ticker", () => ({ - NumberTicker: ({ value }: { value: number }) => React.createElement("span", null, value), -})) -let scrollToDescriptor: PropertyDescriptor | undefined - -// Confirms Phase 4's core claim with an automated test rather than relying -// solely on manual DevTools inspection: a `` mount effect -// fires exactly once across a `loading: true → false` prop transition on -// the SAME rendered DOM instance — i.e. the loading→loaded transition is a -// props change, not an unmount/remount. -describe("MessageList — loading→loaded mount identity (Phase 4)", () => { - afterEach(() => { - if (scrollToDescriptor) { - Object.defineProperty(HTMLElement.prototype, "scrollTo", scrollToDescriptor) - } else { - delete (HTMLElement.prototype as unknown as { scrollTo?: unknown }).scrollTo - } - vi.restoreAllMocks() - vi.unstubAllGlobals() +describe("MessageList loading-to-loaded mount identity", () => { + beforeEach(installMessageScrollFixture) + afterEach(restoreMessageScrollFixture) + it("keeps the scroller and settles the first real row once across the prop transition", () => { + const messages = [{ id: "m1", authorName: "Alice", content: "hi", createdAt: new Date(0).toISOString() }] + const view = (loading: boolean) => React.createElement(MessageList, { channel: "general", messages: loading ? [] : messages, loading, newDividerBefore: "m1", onOpenThread: vi.fn() }) + const renderer = render(view(true)) + resize() + const root = renderer.getByTestId(tid.messageScroller) + renderer.rerender(view(false)) + resize() + expect(renderer.getByTestId(tid.messageScroller)).toBe(root) + expect(renderer.container.querySelector('[data-msg-id="m1"]')).toBeInTheDocument() + expect(renderer.container.querySelector('[data-message-list-content]')).toHaveAttribute("data-read-position-ready", "true") + expect(renderer.container.querySelector('[data-message-list-content]')).toHaveAttribute("aria-hidden", "false") + const calls = scrollFixture.scrollCalls.length + renderer.rerender(view(false)) + resize() + expect(renderer.getByTestId(tid.messageScroller)).toBe(root) + expect(scrollFixture.scrollCalls).toHaveLength(calls) }) - - it("does not re-fire the mount-time scroll effect when transitioning loading:true → loading:false on one instance", () => { - const scrollTo = vi.fn() - scrollToDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollTo") - Object.defineProperty(HTMLElement.prototype, "scrollTo", { - configurable: true, - value: scrollTo, - }) - vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(1000) - vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(500) - vi.stubGlobal("ResizeObserver", class { - observe() {} - unobserve() {} - disconnect() {} - }) - vi.stubGlobal("IntersectionObserver", class { - observe() {} - unobserve() {} - disconnect() {} - }) + it("keeps true-empty typing clearance and removes that spacer when the first native row arrives", () => { const messages = [{ id: "m1", authorName: "Alice", content: "hi", createdAt: new Date(0).toISOString() }] - const renderer = render( - React.createElement(MessageList, { - channel: "general", - messages: [], - loading: true, - onOpenThread: vi.fn(), - }), - ) - - // The initial mount has no messages, so the one-shot action bails. - const callsBeforeLoaded = scrollTo.mock.calls.length - - renderer.rerender( - React.createElement(MessageList, { - channel: "general", - messages, - loading: false, - onOpenThread: vi.fn(), - }), - ) - - expect(scrollTo).toHaveBeenCalledTimes(callsBeforeLoaded + 1) - const positionedContent = renderer.container.querySelector( - "[data-message-list-content]", - ) - expect(positionedContent).toHaveAttribute("data-initial-position-phase", "positioning") - expect(positionedContent).toHaveAttribute("aria-hidden", "true") - expect(positionedContent).toHaveAttribute("inert") - expect(positionedContent).toHaveClass("pointer-events-none", "opacity-0") - expect(renderer.container.querySelector("[data-message-positioning-skeleton]")) - .toBeInTheDocument() - - renderer.rerender( - React.createElement(MessageList, { - channel: "general", - messages, - loading: false, - onOpenThread: vi.fn(), - }), - ) - expect(scrollTo).toHaveBeenCalledTimes(callsBeforeLoaded + 1) + const view = (empty: boolean) => React.createElement(MessageList, { channel: "general", messages: empty ? [] : messages, loading: false, typingUsers: ["Alice"], onOpenThread: vi.fn() }) + const renderer = render(view(true)) + resize() + expect(renderer.container.querySelector('[data-msg-id]')).toBeNull() + expect(renderer.container.querySelector('[data-message-empty-tail]')).toHaveClass("h-10", "sm:h-12") + expect(renderer.getByTestId(tid.composerAccessoryRail)).toBeInTheDocument() + expect(renderer.getByTestId(tid.typingIndicator)).toHaveTextContent("Alice is typing") + const root = renderer.getByTestId(tid.messageScroller) + renderer.rerender(view(false)) + resize() + expect(renderer.getByTestId(tid.messageScroller)).toBe(root) + expect(renderer.container.querySelector('[data-message-empty-tail]')).toBeNull() + expect(renderer.container.querySelectorAll('[data-index]')).toHaveLength(1) + expect(root.querySelector('[data-message-list-content] > div')?.style.height).toBe("500px") + expect(root.querySelector('[data-message-list-content]')).toHaveAttribute("data-read-position-ready", "true") }) }) diff --git a/src/web/src/components/community/messages/message-list.older-load.dom.test.ts b/src/web/src/components/community/messages/message-list.older-load.dom.test.ts index 61588b69e..c4b8e05ec 100644 --- a/src/web/src/components/community/messages/message-list.older-load.dom.test.ts +++ b/src/web/src/components/community/messages/message-list.older-load.dom.test.ts @@ -1,131 +1,56 @@ import React from "react" -import { afterEach, describe, expect, it, vi } from "vitest" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { tid } from "@/lib/community/testids" -import { act, fireEvent, render, type RenderResult } from "@/test/react-dom-harness" +import { act, fireEvent, render } from "@/test/react-dom-harness" import { MessageList } from "./message-list" +import { installMessageScrollFixture, restoreMessageScrollFixture, resize, runFrames } from "@/test/message-scroll-fixture" vi.mock("@/hooks/use-mobile", () => ({ useBreakpoint: () => "desktop" })) - -vi.mock("@/components/ui/number-ticker", () => ({ - NumberTicker: ({ value }: { value: number }) => React.createElement("span", null, value), -})) -let scrollTopDescriptor: PropertyDescriptor | undefined -let scrollToDescriptor: PropertyDescriptor | undefined - -function restorePrototypeProperty(name: "scrollTo" | "scrollTop", descriptor?: PropertyDescriptor) { - if (descriptor) Object.defineProperty(HTMLElement.prototype, name, descriptor) - else delete (HTMLElement.prototype as unknown as Record)[name] -} - -describe("MessageList — older-load sentinel does not cascade", () => { - afterEach(() => { - restorePrototypeProperty("scrollTop", scrollTopDescriptor) - restorePrototypeProperty("scrollTo", scrollToDescriptor) - vi.restoreAllMocks() - vi.unstubAllGlobals() - }) - - it("fires onLoadOlder once per intersection, not on every fetch-state re-render", () => { - let ioCallback: ((entries: Array<{ isIntersecting: boolean }>) => void) | null = null - const scrollPositions = new WeakMap() - scrollTopDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollTop") - scrollToDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollTo") - Object.defineProperty(HTMLElement.prototype, "scrollTop", { - configurable: true, - get() { return scrollPositions.get(this) ?? 500 }, - set(value: number) { scrollPositions.set(this, value) }, - }) - Object.defineProperty(HTMLElement.prototype, "scrollTo", { - configurable: true, - value: vi.fn(), - }) - vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(1000) - vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(500) - const removeEventListener = vi.spyOn(EventTarget.prototype, "removeEventListener") - - vi.stubGlobal("ResizeObserver", class { - observe() {} - unobserve() {} - disconnect() {} - }) +vi.mock("./message-row", () => ({ MessageRow: () => React.createElement("div") })) +vi.mock("@/components/ui/number-ticker", () => ({ NumberTicker: ({ value }: { value: number }) => React.createElement("span", null, value) })) + +describe("MessageList virtual older sentinel does not cascade", () => { + beforeEach(installMessageScrollFixture) + afterEach(restoreMessageScrollFixture) + it("loads once per intersection, retains its fetch lock, and accepts fresh edge input", () => { + let callback: IntersectionObserverCallback + let element: Element + const disconnected = vi.fn() vi.stubGlobal("IntersectionObserver", class { - constructor(callback: (entries: Array<{ isIntersecting: boolean }>) => void) { - if (!ioCallback) ioCallback = callback - } - observe() {} - unobserve() {} - disconnect() {} + constructor(next: IntersectionObserverCallback) { callback = next } + observe(target: Element) { element = target } + disconnect() { disconnected() } }) - - const messages = [{ - id: "m1", - authorName: "Alice", - content: "hi", - createdAt: new Date(0).toISOString(), - }] + const messages = Array.from({ length: 14 }, (_, i) => ({ id: `m${i}`, authorName: "Alice", content: "hi", createdAt: new Date(0).toISOString() })) const onLoadOlder = vi.fn() - const view = (isFetchingOlder: boolean, hasMore = true) => React.createElement(MessageList, { - channel: "general", - messages, - loading: false, - hasMore, - onLoadOlder, - isFetchingOlder, - onOpenThread: vi.fn(), - }) - const intersect = () => act(() => ioCallback!([{ isIntersecting: true }])) - let renderer: RenderResult = render(view(false)) - let scroller = renderer.getByTestId(tid.messageScroller) - - expect(ioCallback).not.toBeNull() - + const view = (isFetchingOlder: boolean, hasMore = true) => React.createElement(MessageList, { channel: "general", messages, loading: false, hasMore, onLoadOlder, isFetchingOlder, onOpenThread: vi.fn() }) + const intersect = (isIntersecting = true) => act(() => callback([{ isIntersecting, target: element } as IntersectionObserverEntry], {} as IntersectionObserver)) + const renderer = render(view(false)) + resize() + const scroller = renderer.getByTestId(tid.messageScroller) + fireEvent.wheel(scroller, { deltaY: -20 }) + act(() => { scroller.scrollTo({ top: 0 }) }) + runFrames() + resize() + expect(element!.isConnected).toBe(true) intersect() - expect(onLoadOlder).toHaveBeenCalledTimes(1) - - renderer.rerender(view(true)) - renderer.rerender(view(false)) - expect(onLoadOlder).toHaveBeenCalledTimes(1) - - renderer.rerender(view(true)) - scroller.scrollTop = 250 - fireEvent.scroll(scroller) - expect(onLoadOlder).toHaveBeenCalledTimes(1) - renderer.rerender(view(false)) - expect(onLoadOlder).toHaveBeenCalledTimes(1) - + expect(onLoadOlder).toHaveBeenCalledOnce() renderer.rerender(view(true)) - scroller.scrollTop = 0 - fireEvent.scroll(scroller) - scroller.scrollTop = 150 - fireEvent.scroll(scroller) - scroller.scrollTop = 0 - fireEvent.scroll(scroller) - expect(onLoadOlder).toHaveBeenCalledTimes(1) - renderer.rerender(view(false)) - expect(onLoadOlder).toHaveBeenCalledTimes(1) - + resize() + expect(onLoadOlder).toHaveBeenCalledOnce() + act(() => { scroller.scrollTo({ top: 0 }) }) fireEvent.wheel(scroller, { deltaY: -20 }) expect(onLoadOlder).toHaveBeenCalledTimes(2) - - renderer.rerender(view(false)) renderer.rerender(view(false)) + intersect(false) + intersect(true) expect(onLoadOlder).toHaveBeenCalledTimes(2) - renderer.rerender(view(true)) - intersect() renderer.rerender(view(false, false)) - expect(onLoadOlder).toHaveBeenCalledTimes(2) - - renderer.unmount() - ioCallback = null - renderer = render(view(true)) - scroller = renderer.getByTestId(tid.messageScroller) - scroller.scrollTop = 500 - intersect() + resize() expect(onLoadOlder).toHaveBeenCalledTimes(2) renderer.unmount() - expect(removeEventListener).toHaveBeenCalledWith("wheel", expect.any(Function)) - expect(onLoadOlder).toHaveBeenCalledTimes(2) + expect(disconnected).toHaveBeenCalled() }) }) diff --git a/src/web/src/components/community/messages/message-list.tsx b/src/web/src/components/community/messages/message-list.tsx index 49f1fe1f0..b73df64ab 100644 --- a/src/web/src/components/community/messages/message-list.tsx +++ b/src/web/src/components/community/messages/message-list.tsx @@ -1,5 +1,6 @@ "use client" +import { isConversationAccessError } from "@/lib/community/conversation-read" import { useLayoutEffect } from "react" import { useMessageListController } from "./message-list-controller" import { useHoverCapable } from "@/hooks/use-hover-capable" @@ -17,6 +18,7 @@ export function MessageList({ const hoverCapable = useHoverCapable() const resolvedProps: ResolvedMessageListProps = { ...props, + messages: isConversationAccessError(props.initialLoadError) ? [] : props.messages, variant, initialScrollReady, hoverCapable, @@ -33,7 +35,7 @@ export function MessageList({ items={controller.items} virtualizer={controller.virtualizer} itemKey={(item) => item.key} - renderItem={(item) => renderMessageListRow(item, resolvedProps, controller)} + renderItem={(item, index) => renderMessageListRow(item, resolvedProps, controller, index)} /> ), footerSlot?.target ?? null) } diff --git a/src/web/src/components/community/messages/virtualizer-direct-dom.contract.test.ts b/src/web/src/components/community/messages/virtualizer-direct-dom.contract.test.ts index 8cc11646b..7ad08b365 100644 --- a/src/web/src/components/community/messages/virtualizer-direct-dom.contract.test.ts +++ b/src/web/src/components/community/messages/virtualizer-direct-dom.contract.test.ts @@ -16,7 +16,7 @@ describe("community virtualizer direct DOM contract", () => { expect(messageList).toContain(" vi.fn()) vi.mock("@/lib/api/client", () => ({ apiFetch: (...args: unknown[]) => fetchMock(...args) })) @@ -19,6 +20,50 @@ beforeEach(() => { }) describe("canonical channel metadata lifecycle", () => { + it.each(["text", "forum", "thread", "dm"])("settles hanging %s metadata without publishing a late grant, then retries", async (type) => { + const { client, runtime } = await createCommunityQueryOwner() + const serverId = type === "dm" ? null : "server" + const payload = { ...metadata, serverId, type, name: type === "dm" ? null : metadata.name } + let release!: (value: unknown) => void + fetchMock.mockImplementationOnce(() => new Promise((resolve) => { release = resolve })) + vi.useFakeTimers() + try { + const options = channelMetadataOptions(client, serverId, "child") + const result = client.fetchQuery(options) + const rejected = expect(result).rejects.toMatchObject({ name: "ConversationReadTimeoutError" }) + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()) + const readSignal = fetchMock.mock.calls[0]![1].signal as AbortSignal + await vi.advanceTimersByTimeAsync(CONVERSATION_READ_TIMEOUT_MS) + await rejected + expect(readSignal.aborted).toBe(true) + release(payload) + await Promise.resolve() + await Promise.resolve() + expect(runtime.ws.get().channelAccessScopes.has("child")).toBe(false) + expect(client.getQueryData(options.queryKey)).toBeUndefined() + fetchMock.mockResolvedValueOnce(payload) + await expect(client.fetchQuery(options)).resolves.toMatchObject({ id: "child" }) + expect(runtime.ws.get().channelAccessScopes.has("child")).toBe(type !== "dm") + expect(fetchMock).toHaveBeenCalledTimes(2) + } finally { vi.useRealTimers() } + }) + + it("includes collection preload in the metadata deadline and never starts retired HTTP", async () => { + const { client, registry } = await createCommunityQueryOwner() + let release!: () => void + vi.spyOn(registry.collections.channels, "preload").mockImplementationOnce(() => new Promise((resolve) => { release = resolve })) + vi.useFakeTimers() + try { + const result = client.fetchQuery(channelMetadataOptions(client, "server", "child")) + const rejected = expect(result).rejects.toMatchObject({ name: "ConversationReadTimeoutError" }) + await vi.advanceTimersByTimeAsync(CONVERSATION_READ_TIMEOUT_MS) + await rejected + release() + await Promise.resolve() + expect(fetchMock).not.toHaveBeenCalled() + } finally { vi.useRealTimers() } + }) + it.each(["text", "forum", "thread", "dm"])("validates the exact %s resource with its nullable scope", async (type) => { const serverId = type === "dm" ? null : "server" fetchMock.mockResolvedValue({ ...metadata, serverId, type, name: type === "dm" ? null : metadata.name }) diff --git a/src/web/src/hooks/community/channel-metadata.ts b/src/web/src/hooks/community/channel-metadata.ts index c99e9b4a0..77ddf8821 100644 --- a/src/web/src/hooks/community/channel-metadata.ts +++ b/src/web/src/hooks/community/channel-metadata.ts @@ -1,7 +1,7 @@ import { apiFetch } from "@/lib/api/client" import { queryOptions, type QueryClient } from "@tanstack/react-query" import { communityKeys } from "@/lib/query-keys" -import { ApiError } from "@/lib/errors" +import { retryConversationRead, withConversationReadDeadline } from "@/lib/community/conversation-read" import { captureCommunityLiveSnapshotToken, assertCommunityLiveSnapshotTokenCurrent, publishCommunityChannelMetadata } from "@/lib/community-db/sync" import { communityRequestOptions } from "@/lib/community/account-cache-lifecycle" @@ -33,7 +33,7 @@ export function isChannelMetadataTokenCurrent(token: ReturnType + verification?: ReturnType historyVerification?: ReturnType } @@ -44,28 +44,30 @@ export async function fetchChannelMetadata( signal?: AbortSignal, token = captureChannelMetadataToken(queryClient, channelId), ) { - const assertActive = () => { - if (signal?.aborted || !isChannelMetadataTokenCurrent(token)) throw new DOMException("Stale channel metadata", "AbortError") - } - assertActive() - await token.registry!.ready - assertActive() - await token.registry!.collections.channels.preload() - assertActive() - let meta: ChannelMetadata - try { - meta = await apiFetch(`/api/community/channels/${encodeURIComponent(channelId)}`, communityRequestOptions(queryClient, token, signal, assertActive)) + return withConversationReadDeadline(signal, async (readSignal) => { + const assertActive = () => { + if (readSignal.aborted || !isChannelMetadataTokenCurrent(token)) throw new DOMException("Stale channel metadata", "AbortError") + } + assertActive() + await token.registry!.ready assertActive() - } catch (error) { assertActive(); throw error } - if (meta.id !== channelId || meta.serverId !== serverId - || !(serverId === null ? meta.type === "dm" : ["text", "forum", "thread"].includes(meta.type)) - || !(typeof meta.name === "string" || (serverId === null && meta.name === null))) throw new Error("Channel metadata scope mismatch") - if (serverId !== null) { - token.registry!.runtime.ws.actions.grantServerAccess(serverId) - token.registry!.runtime.ws.actions.rememberChannelAccess(serverId, channelId, meta.parentChannelId) - } - return { ...meta, name: meta.name ?? "", archived: meta.archived === true || meta.archived === 1, - activityAt: meta.lastMessageAt ?? meta.createdAt, verifiedEpoch: token.accessEpoch, verification: token } + await token.registry!.collections.channels.preload() + assertActive() + let meta: ChannelMetadata + try { + meta = await apiFetch(`/api/community/channels/${encodeURIComponent(channelId)}`, communityRequestOptions(queryClient, token, readSignal, assertActive)) + assertActive() + } catch (error) { assertActive(); throw error } + if (meta.id !== channelId || meta.serverId !== serverId + || !(serverId === null ? meta.type === "dm" : ["text", "forum", "thread"].includes(meta.type)) + || !(typeof meta.name === "string" || (serverId === null && meta.name === null))) throw new Error("Channel metadata scope mismatch") + if (serverId !== null) { + token.registry!.runtime.ws.actions.grantServerAccess(serverId) + token.registry!.runtime.ws.actions.rememberChannelAccess(serverId, channelId, meta.parentChannelId) + } + return { ...meta, name: meta.name ?? "", archived: meta.archived === true || meta.archived === 1, + activityAt: meta.lastMessageAt ?? meta.createdAt, verifiedEpoch: token.accessEpoch, verification: token } + }) } export function channelMetadataOptions(queryClient: QueryClient, serverId: string | null, channelId: string) { @@ -81,6 +83,7 @@ export function channelMetadataOptions(queryClient: QueryClient, serverId: strin }, staleTime: Infinity, gcTime: 5 * 60 * 1000, - retry: (failureCount, error) => !(error instanceof ApiError && [401, 403, 404].includes(error.status)) && failureCount < 1, + retry: retryConversationRead, + networkMode: "always", }) } diff --git a/src/web/src/hooks/community/community-ws/membership-events.dom.test.ts b/src/web/src/hooks/community/community-ws/membership-events.dom.test.ts index 3da1eef03..b057fcff5 100644 --- a/src/web/src/hooks/community/community-ws/membership-events.dom.test.ts +++ b/src/web/src/hooks/community/community-ws/membership-events.dom.test.ts @@ -4,7 +4,10 @@ import { seedCanonicalFocusedChannel } from "./test-harness" import { getCapturedRuntime, seedCanonicalStream, mountCanonicalHook } from "./test-harness" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { QueryObserver } from "@tanstack/react-query" +import { ApiError } from "@/lib/errors" +import { channelMetadataOptions, isChannelMetadataTokenCurrent } from "@/hooks/community/channel-metadata" import type { CommunityMemberJoin, CommunityMemberLeave, CommunityMemberUpdate } from "@alook/shared" +import { deriveCommunityDeliveryOperationId, encodeCommunityBrowserEventBatch, prepareCommunityDeliveryEvents } from "@alook/shared" import { getMessageOverlay } from "@/stores/community/message-stream" import type { PresenceResponse } from "@/hooks/community/use-server-panels" import { communityKeys } from "@/lib/query-keys" @@ -607,6 +610,148 @@ describe("useCommunityWs — channel.member_add/remove → invalidate rosters", }) describe("membership metadata and access lifetime", () => { + it.each(["text", "forum"] as const)("replaces an in-flight %s child metadata read when participation changes again", async (parentType) => { + await mountHook({ viewerUserId: "u_me" }) + seedCanonicalThread("server", "parent", parentType, "child") + const payload = { + id: "child", serverId: "server", type: "thread", parentChannelId: "parent", + parentMessageId: "opener", creatorId: "u_me", name: "Current child", archived: false, + lastMessageAt: null, createdAt: "2026-08-01T00:00:00.000Z", + } + const api = getCommunityApiFetchMock() + api.mockResolvedValue(payload) + const metadata = await mountCanonicalHook(() => useChannelMetadata("server", "child")) + await vi.waitFor(() => expect(metadata.result.current.isVerified).toBe(true)) + const key = communityKeys.channelMessages("child") + const content = { pages: [{ messages: [{ id: "m1" }] }] } + act(() => capturedQueryClient.setQueryData(key, content)) + const reads: Array<{ signal: AbortSignal; resolve: (value: unknown) => void; reject: (error: unknown) => void }> = [] + api.mockImplementation(async (...args: unknown[]) => { + if (args[0] !== "/api/community/channels/child") return payload + return await new Promise((resolve, reject) => { + reads.push({ signal: (args[1] as { signal: AbortSignal }).signal, resolve, reject }) + }) + }) + capturedOnMessage!({ type: "community:channel.member_add", channelId: "child", serverId: "server", userId: "u_me" }) + await vi.waitFor(() => expect(reads).toHaveLength(1)) + capturedOnMessage!({ type: "community:channel.member_remove", channelId: "child", serverId: "server", userId: "u_me" }) + await vi.waitFor(() => expect(reads).toHaveLength(2)) + expect(reads[0]!.signal.aborted).toBe(true) + expect(reads[1]!.signal.aborted).toBe(false) + await act(async () => { + if (parentType === "text") reads[0]!.resolve({ ...payload, name: "Old archived child", archived: true }) + else reads[0]!.reject(new ApiError("Old denial", 403)) + }) + expect(metadata.result.current.data?.name).not.toBe("Old archived child") + expect(metadata.result.current.isVerified).toBe(false) + await act(async () => reads[1]!.resolve(payload)) + await vi.waitFor(() => expect(metadata.result.current.isVerified).toBe(true)) + expect(isChannelMetadataTokenCurrent(metadata.result.current.data!.verification!)).toBe(true) + expect(metadata.result.current.data).toMatchObject({ name: "Current child", archived: false }) + expect(capturedQueryClient.getQueryData(key)).toEqual(content) + expect(hasCanonicalChannelAccess("child", "u_me")).toBe(true) + expect(hasCanonicalChannelNotify("child", "u_me")).toBe(false) + expect(getCapturedRuntime().ws.actions.isChannelAccessRevoked("child")).toBe(false) + }) + + it("hands one batched add/remove to the latest participation generation", async () => { + await mountHook({ viewerUserId: "u_me" }) + seedCanonicalThread("server", "parent", "text", "child") + const payload = { + id: "child", serverId: "server", type: "thread", parentChannelId: "parent", parentMessageId: "opener", + creatorId: "u_me", name: "Child", archived: false, lastMessageAt: null, createdAt: "2026-08-01T00:00:00.000Z", + } + const api = getCommunityApiFetchMock() + api.mockResolvedValue(payload) + const metadata = await mountCanonicalHook(() => useChannelMetadata("server", "child")) + await vi.waitFor(() => expect(metadata.result.current.isVerified).toBe(true)) + const before = api.mock.calls.filter(([path]) => path === "/api/community/channels/child").length + const events = [ + { type: "community:channel.member_add", channelId: "child", serverId: "server", userId: "u_me" }, + { type: "community:channel.member_remove", channelId: "child", serverId: "server", userId: "u_me" }, + ] as const + const prepared = await prepareCommunityDeliveryEvents(events) + if (!prepared.ok) throw new Error("Participant events must prepare") + const encoded = await encodeCommunityBrowserEventBatch({ + operationId: await deriveCommunityDeliveryOperationId("participation-change"), + operationDigest: prepared.prepared.digest, + events, + }) + if (!encoded.ok) throw new Error("Participant events must encode") + act(() => capturedOnMessage!(encoded.batch)) + await vi.waitFor(() => expect(metadata.result.current.isVerified).toBe(true)) + expect(api.mock.calls.filter(([path]) => path === "/api/community/channels/child")).toHaveLength(before + 1) + expect(isChannelMetadataTokenCurrent(metadata.result.current.data!.verification!)).toBe(true) + expect(hasCanonicalChannelNotify("child", "u_me")).toBe(false) + }) + + it("settles a failed current-generation read without rebuilding it and recovers on explicit Retry", async () => { + await mountHook({ viewerUserId: "u_me" }) + seedCanonicalThread("server", "parent", "forum", "child") + const payload = { + id: "child", serverId: "server", type: "thread", parentChannelId: "parent", parentMessageId: "opener", + creatorId: "u_me", name: "Child", archived: false, lastMessageAt: null, createdAt: "2026-08-01T00:00:00.000Z", + } + const api = getCommunityApiFetchMock() + api.mockResolvedValue(payload) + const metaKey = communityKeys.channelMeta("server", "child") + capturedQueryClient.setQueryDefaults(metaKey, { retryDelay: 0 }) + const metadata = await mountCanonicalHook(() => useChannelMetadata("server", "child")) + await vi.waitFor(() => expect(metadata.result.current.isVerified).toBe(true)) + const before = api.mock.calls.filter(([path]) => path === "/api/community/channels/child").length + api.mockRejectedValue(new ApiError("temporary", 503)) + capturedOnMessage!({ type: "community:channel.member_remove", channelId: "child", serverId: "server", userId: "u_me" }) + await vi.waitFor(() => expect(metadata.result.current).toMatchObject({ isError: true, fetchStatus: "idle", isVerified: false })) + expect(api.mock.calls.filter(([path]) => path === "/api/community/channels/child")).toHaveLength(before + 2) + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 20)) }) + expect(api.mock.calls.filter(([path]) => path === "/api/community/channels/child")).toHaveLength(before + 2) + api.mockResolvedValue(payload) + await act(async () => { await metadata.result.current.refetch({ cancelRefetch: false }) }) + await vi.waitFor(() => expect(metadata.result.current.isVerified).toBe(true)) + expect(api.mock.calls.filter(([path]) => path === "/api/community/channels/child")).toHaveLength(before + 3) + }) + + it.each(["parent", "server", "account", "unmount"] as const)("does not restart participation metadata after %s retirement before handoff", async (race) => { + await mountHook({ viewerUserId: "u_me" }) + seedCanonicalThread("server", "parent", "forum", "child") + const payload = { + id: "child", serverId: "server", type: "thread", parentChannelId: "parent", parentMessageId: "opener", + creatorId: "u_me", name: "Child", archived: false, lastMessageAt: null, createdAt: "2026-08-01T00:00:00.000Z", + } + const api = getCommunityApiFetchMock() + api.mockResolvedValue(payload) + const metadata = await mountCanonicalHook(() => useChannelMetadata("server", "child")) + await vi.waitFor(() => expect(metadata.result.current.isVerified).toBe(true)) + const metaKey = communityKeys.channelMeta("server", "child") + const original = capturedQueryClient.getQueryCache().find({ queryKey: metaKey, exact: true }) + const observer = race === "account" + ? new QueryObserver(capturedQueryClient, channelMetadataOptions(capturedQueryClient, "server", "child")) : null + const unsubscribe = observer?.subscribe(() => {}) + if (race === "account") act(() => metadata.unmount()) + let release!: () => void + const gate = new Promise((resolve) => { release = resolve }) + const cancel = capturedQueryClient.cancelQueries.bind(capturedQueryClient) + vi.spyOn(capturedQueryClient, "cancelQueries").mockImplementation(async (...args) => { await cancel(...args); await gate }) + const before = api.mock.calls.filter(([path]) => path === "/api/community/channels/child").length + await act(async () => { + capturedOnMessage!({ type: "community:channel.member_remove", channelId: "child", serverId: "server", userId: "u_me" }) + if (race === "parent") capturedOnMessage!({ type: "community:channel.member_remove", channelId: "parent", serverId: "server", userId: "u_me" }) + else if (race === "server") capturedOnMessage!({ type: "community:member.leave", serverId: "server", userId: "u_me" }) + else if (race === "account") getCapturedRuntime().ws.actions.activateProfileAccount("other") + else metadata.unmount() + }) + await act(async () => { release(); await Promise.resolve(); await Promise.resolve() }) + expect(api.mock.calls.filter(([path]) => path === "/api/community/channels/child")).toHaveLength(before) + if (race === "parent" || race === "server") { + const current = capturedQueryClient.getQueryCache().find({ queryKey: metaKey, exact: true }) + expect(current).not.toBe(original) + expect(current?.state.data).toBeUndefined() + expect(hasCanonicalChannel("child")).toBe(false) + expect(getCapturedRuntime().ws.actions.isChannelAccessRevoked("child", "server")).toBe(true) + } + unsubscribe?.() + }) + it.each(["text", "forum"] as const)("keeps %s child content after notify removal with ID-only metadata", async (parentType) => { await mountHook({ viewerUserId: "u_me" }) seedCanonicalThread("server", "parent", parentType, "child") diff --git a/src/web/src/hooks/community/community-ws/membership-events.ts b/src/web/src/hooks/community/community-ws/membership-events.ts index 34fce80d4..ca269b860 100644 --- a/src/web/src/hooks/community/community-ws/membership-events.ts +++ b/src/web/src/hooks/community/community-ws/membership-events.ts @@ -103,6 +103,12 @@ export function handleChannelMemberEvent( } const knownType = canonicalType() if (knownType) { + if (viewerChange && knownType === "thread") { + projection.fence("channel-metadata", { queryKey: key, exact: true }, () => ( + isChannelMetadataTokenCurrent(token) + && !getCommunityRuntime(queryClient).ws.actions.isChannelAccessRevoked(event.channelId, event.serverId) + )) + } apply(knownType) return } diff --git a/src/web/src/hooks/community/community-ws/projection-transaction.test.ts b/src/web/src/hooks/community/community-ws/projection-transaction.test.ts index 10aeb042b..66010c070 100644 --- a/src/web/src/hooks/community/community-ws/projection-transaction.test.ts +++ b/src/web/src/hooks/community/community-ws/projection-transaction.test.ts @@ -56,9 +56,13 @@ describe("community WS projection transaction", () => { it("upgrades a normal invalidation to one exact fence in either order", async () => { for (const order of ["invalidate-first", "fence-first"] as const) { const queryClient = new QueryClient() + const observer = new QueryObserver(queryClient, { + queryKey: ["community", "servers"], queryFn: async () => "current", initialData: "prior", staleTime: Infinity, + }) + const unsubscribe = observer.subscribe(() => {}) const cancel = vi.spyOn(queryClient, "cancelQueries").mockResolvedValue() const invalidate = vi.spyOn(queryClient, "invalidateQueries").mockResolvedValue() - const refetch = vi.spyOn(queryClient, "refetchQueries").mockResolvedValue() + const refetch = vi.spyOn(queryClient, "fetchQuery").mockResolvedValue("current") const filters = { queryKey: ["community", "servers"] as const, exact: true } runCommunityWsProjectionTransaction(queryClient, (transaction) => { @@ -69,13 +73,19 @@ describe("community WS projection transaction", () => { await vi.waitFor(() => expect(refetch).toHaveBeenCalledTimes(1)) expect(cancel).toHaveBeenCalledTimes(1) - expect(invalidate).toHaveBeenCalledWith({ ...filters, refetchType: "none" }) - expect(refetch).toHaveBeenCalledWith({ ...filters, type: "active" }) + expect(invalidate).toHaveBeenCalledWith(expect.objectContaining({ ...filters, refetchType: "none", predicate: expect.any(Function) })) + expect(refetch).toHaveBeenCalledWith(expect.objectContaining({ queryKey: filters.queryKey, staleTime: 0 })) + unsubscribe() + queryClient.clear() } }) it("starts cancellation before projection and waits for it before refetch", async () => { const queryClient = new QueryClient() + const observer = new QueryObserver(queryClient, { + queryKey: ["community", "servers"], queryFn: async () => "current", initialData: "prior", staleTime: Infinity, + }) + const unsubscribe = observer.subscribe(() => {}) let release!: () => void const cancellation = new Promise((resolve) => { release = resolve }) const order: string[] = [] @@ -86,8 +96,9 @@ describe("community WS projection transaction", () => { const invalidate = vi.spyOn(queryClient, "invalidateQueries").mockImplementation(async () => { order.push("invalidate") }) - vi.spyOn(queryClient, "refetchQueries").mockImplementation(async () => { + vi.spyOn(queryClient, "fetchQuery").mockImplementation(async () => { order.push("refetch") + return "current" }) runCommunityWsProjectionTransaction(queryClient, (transaction) => { @@ -101,6 +112,8 @@ describe("community WS projection transaction", () => { await vi.waitFor(() => expect(order).toContain("refetch")) expect(invalidate).toHaveBeenCalledTimes(1) expect(order).toEqual(["cancel", "project", "invalidate", "refetch"]) + unsubscribe() + queryClient.clear() }) it("upgrades one owner/filter entry and replaces each active query exactly once", async () => { @@ -137,7 +150,7 @@ describe("community WS projection transaction", () => { }) expect(cancel).toHaveBeenCalledTimes(1) expect(invalidate).toHaveBeenCalledTimes(1) - expect(invalidate).toHaveBeenCalledWith({ queryKey: prefix, refetchType: "none" }) + expect(invalidate).toHaveBeenCalledWith(expect.objectContaining({ queryKey: prefix, refetchType: "none", predicate: expect.any(Function) })) } finally { unsubscribeAll() unsubscribeArchived() @@ -178,7 +191,7 @@ describe("community WS projection transaction", () => { expect(cancel).toHaveBeenCalledOnce() expect(invalidate).toHaveBeenCalledOnce() - expect(invalidate).toHaveBeenCalledWith({ queryKey: prefix, refetchType: "none" }) + expect(invalidate).toHaveBeenCalledWith(expect.objectContaining({ queryKey: prefix, refetchType: "none", predicate: expect.any(Function) })) expect(unhandled).not.toHaveBeenCalled() } finally { process.off("unhandledRejection", unhandled) @@ -186,6 +199,87 @@ describe("community WS projection transaction", () => { } }) + it.each(["removed", "replaced", "unobserved", "retired"] as const)("does not hand a fenced read to a %s owner", async (race) => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const key = ["original-read"] + const read = vi.fn(async () => "fresh") + const observer = new QueryObserver(client, { queryKey: key, queryFn: read, initialData: "prior", staleTime: Infinity }) + const unsubscribe = observer.subscribe(() => {}) + let release!: () => void + const gate = new Promise((resolve) => { release = resolve }) + const cancel = client.cancelQueries.bind(client) + vi.spyOn(client, "cancelQueries").mockImplementation(async (...args) => { await cancel(...args); await gate }) + const invalidate = vi.spyOn(client, "invalidateQueries") + const replacementRead = vi.fn(async () => "replacement-fresh") + let unsubscribeReplacement: (() => void) | undefined + let current = true + try { + runCommunityWsProjectionTransaction(client, (transaction) => { + transaction.fence("original", { queryKey: key, exact: true }, () => current) + }) + if (race === "unobserved") unsubscribe() + else if (race === "retired") current = false + else { + client.removeQueries({ queryKey: key, exact: true }) + if (race === "replaced") { + const replacement = new QueryObserver(client, { + queryKey: key, queryFn: replacementRead, initialData: "replacement", staleTime: Infinity, + }) + unsubscribeReplacement = replacement.subscribe(() => {}) + } + } + release() + await new Promise((resolve) => setImmediate(resolve)) + await new Promise((resolve) => setImmediate(resolve)) + expect(read).not.toHaveBeenCalled() + expect(replacementRead).not.toHaveBeenCalled() + if (race === "removed") expect(client.getQueryState(key)).toBeUndefined() + if (race === "replaced") expect(client.getQueryState(key)).toMatchObject({ data: "replacement", isInvalidated: false }) + if (race === "retired") expect(invalidate).not.toHaveBeenCalled() + } finally { unsubscribe(); unsubscribeReplacement?.(); client.clear() } + }) + + it("uses the latest eligibility when a batch fences the same original Query twice", async () => { + const client = new QueryClient() + const key = ["current-generation"] + const read = vi.fn(async () => "fresh") + const observer = new QueryObserver(client, { queryKey: key, queryFn: read, initialData: "prior", staleTime: Infinity }) + const unsubscribe = observer.subscribe(() => {}) + const cancel = vi.spyOn(client, "cancelQueries") + try { + runCommunityWsProjectionTransaction(client, (transaction) => { + transaction.fence("current", { queryKey: key, exact: true }, () => false) + transaction.fence("current", { queryKey: key, exact: true }, () => true) + }) + await vi.waitFor(() => expect(read).toHaveBeenCalledOnce()) + expect(cancel).toHaveBeenCalledOnce() + expect(observer.getCurrentResult().data).toBe("fresh") + } finally { unsubscribe(); client.clear() } + }) + + it("rechecks eligibility after invalidation before starting the replacement transport", async () => { + const client = new QueryClient() + const key = ["retired-during-invalidation"] + const read = vi.fn(async () => "fresh") + const observer = new QueryObserver(client, { queryKey: key, queryFn: read, initialData: "prior", staleTime: Infinity }) + const unsubscribe = observer.subscribe(() => {}) + let release!: () => void + const gate = new Promise((resolve) => { release = resolve }) + const invalidate = client.invalidateQueries.bind(client) + const held = vi.spyOn(client, "invalidateQueries").mockImplementation(async (...args) => { await invalidate(...args); await gate }) + let current = true + try { + runCommunityWsProjectionTransaction(client, (transaction) => { + transaction.fence("current", { queryKey: key, exact: true }, () => current) + }) + await vi.waitFor(() => expect(held).toHaveBeenCalledOnce()) + current = false + release() + await new Promise((resolve) => setImmediate(resolve)) + expect(read).not.toHaveBeenCalled() + } finally { unsubscribe(); client.clear() } + }) + it("flushes queued invalidations when projection fails", () => { const queryClient = new QueryClient() const invalidate = vi.spyOn(queryClient, "invalidateQueries").mockResolvedValue() diff --git a/src/web/src/hooks/community/community-ws/projection-transaction.ts b/src/web/src/hooks/community/community-ws/projection-transaction.ts index 93cdceaa9..88a4a2687 100644 --- a/src/web/src/hooks/community/community-ws/projection-transaction.ts +++ b/src/web/src/hooks/community/community-ws/projection-transaction.ts @@ -4,6 +4,7 @@ import { type InvalidateQueryFilters, type FetchQueryOptions, type QueryClient, + type Query, } from "@tanstack/react-query" const projectionFlushErrors = new WeakMap() @@ -19,13 +20,15 @@ export function getCommunityWsProjectionFlushError(error: unknown) { export type CommunityWsProjectionTransaction = { project: (effect: () => T) => T invalidate: (owner: string, filters: InvalidateQueryFilters) => void - fence: (owner: string, filters: InvalidateQueryFilters) => void + fence: (owner: string, filters: InvalidateQueryFilters, isCurrent?: () => boolean) => void } type PendingInvalidation = { filters: InvalidateQueryFilters cancellation?: Promise - replacementOptions?: FetchQueryOptions[] + originalQueries?: ReadonlySet + replacements?: Array<{ query: Query; options: FetchQueryOptions }> + isCurrent?: () => boolean } function createProjectionTransaction( @@ -41,40 +44,48 @@ function createProjectionTransaction( const identity = hashKey([owner, filters]) if (!pending.has(identity)) pending.set(identity, { filters }) }, - fence: (owner, filters) => { + fence: (owner, filters, isCurrent) => { if (flushed) throw new Error("community WS projection transaction already flushed") const identity = hashKey([owner, filters]) const current = pending.get(identity) - if (current?.cancellation) return - const replacementOptions = queryClient.getQueryCache().findAll(filters) + if (current?.cancellation) { + current.isCurrent = isCurrent + return + } + const queries = queryClient.getQueryCache().findAll(filters) + const originalQueries = new Set(queries) + const replacements = queries .filter((query) => query.isActive() && query.options.queryFn) - .map((query) => ({ ...query.options, staleTime: 0 } as FetchQueryOptions)) + .map((query) => ({ query, options: { ...query.options, staleTime: 0 } as FetchQueryOptions })) const cancellation = queryClient.cancelQueries(filters) if (current) { current.cancellation = cancellation - current.replacementOptions = replacementOptions + current.originalQueries = originalQueries + current.replacements = replacements + current.isCurrent = isCurrent } else { - pending.set(identity, { filters, cancellation, replacementOptions }) + pending.set(identity, { filters, cancellation, originalQueries, replacements, isCurrent }) } }, flushInvalidations: () => { if (flushed) return flushed = true - for (const { filters, cancellation, replacementOptions } of pending.values()) { + for (const { filters, cancellation, originalQueries, replacements, isCurrent } of pending.values()) { if (cancellation) { void cancellation.then(async () => { + if (isCurrent && !isCurrent()) return + const registered = new Set([...(originalQueries ?? [])].filter((query) => ( + queryClient.getQueryCache().get(query.queryHash) === query + ))) await queryClient.invalidateQueries({ ...filters, + predicate: (query) => registered.has(query) && (!filters.predicate || filters.predicate(query)), refetchType: "none", }) - if (replacementOptions?.length) { - await Promise.all(replacementOptions.map((options) => queryClient.fetchQuery(options))) - } else { - await queryClient.refetchQueries({ - ...filters, - type: "active", - }) - } + if (isCurrent && !isCurrent()) return + await Promise.all((replacements ?? []) + .filter(({ query }) => queryClient.getQueryCache().get(query.queryHash) === query && query.isActive()) + .map(({ options }) => queryClient.fetchQuery(options))) }).catch(() => {}) } else { void queryClient.invalidateQueries(filters) diff --git a/src/web/src/hooks/community/community-ws/read-state-reconciliation.test.ts b/src/web/src/hooks/community/community-ws/read-state-reconciliation.test.ts index bc115a9ea..35af19390 100644 --- a/src/web/src/hooks/community/community-ws/read-state-reconciliation.test.ts +++ b/src/web/src/hooks/community/community-ws/read-state-reconciliation.test.ts @@ -2,6 +2,7 @@ import { createCommunityDbRegistry } from "@/lib/community-db/collections" import { beforeEach, describe, expect, it, vi } from "vitest" import { QueryClient, QueryObserver } from "@tanstack/react-query" import { communityKeys } from "@/lib/query-keys" +import { ConversationReadTimeoutError, withConversationReadDeadline } from "@/lib/community/conversation-read" const apiFetch = vi.hoisted(() => vi.fn()) vi.mock("@/lib/api/client", () => ({ @@ -149,6 +150,7 @@ describe("account read-state reconciliation", () => { queryKey: communityKeys.servers(), exact: true, refetchType: "active", + predicate: expect.any(Function), }, { throwOnError: true, cancelRefetch: true }) }) @@ -216,7 +218,7 @@ describe("account read-state reconciliation", () => { expect(serverFetches).toBe(2) expect(directoryFetches).toBe(1) expect(invalidate).toHaveBeenCalledWith( - { queryKey: communityKeys.server("server-1"), exact: true, refetchType: "active" }, + { queryKey: communityKeys.server("server-1"), exact: true, refetchType: "active", predicate: expect.any(Function) }, { throwOnError: true, cancelRefetch: true }, ) expect(invalidate).not.toHaveBeenCalledWith( @@ -715,4 +717,219 @@ describe("account read-state reconciliation", () => { expect(apiFetch).toHaveBeenCalledTimes(2) expect(invalidate).toHaveBeenCalledTimes(6) }) + + it("keeps a necessary cold deadline terminal through retained surface work", async () => { + vi.useFakeTimers() + apiFetch.mockResolvedValue({ revision: 1, readStates: [] }) + let reads = 0 + const observer = new QueryObserver(queryClient, { + queryKey: communityKeys.server("cold"), + queryFn: ({ signal }) => { + reads += 1 + return withConversationReadDeadline(signal, () => new Promise(() => {})) + }, + retryOnMount: false, + }) + const unsubscribe = observer.subscribe(() => {}) + const failure = reconcileAccountReadState(queryClient, { surfaceMode: "non-inbox", targetRevision: 1 }) + .catch((error: unknown) => error) + await vi.advanceTimersByTimeAsync(15_000) + const error = await failure + expect(error).toBeInstanceOf(AggregateError) + expect((error as AggregateError).errors).toEqual([expect.any(ConversationReadTimeoutError)]) + expect(observer.getCurrentResult()).toMatchObject({ status: "error", fetchStatus: "idle", data: undefined }) + expect(queryClient.getQueryData(communityKeys.accountReadStateSnapshot())).toMatchObject({ revision: 1 }) + await vi.advanceTimersByTimeAsync(20_000) + await expect(reconcileAccountReadState(queryClient, { surfaceMode: "non-inbox", targetRevision: 1 })) + .rejects.toThrow("read-state surface reconciliation failed") + expect(reads).toBe(1) + expect(apiFetch).toHaveBeenCalledTimes(1) + unsubscribe() + disposeAccountReadStateReconciliation(queryClient) + vi.useRealTimers() + }) + + it("retries only failed cached targets beside a terminal cold target", async () => { + vi.useFakeTimers() + apiFetch.mockResolvedValue({ revision: 1, readStates: [] }) + const reads = { cold: 0, cached: 0, healthy: 0 } + const observers = [ + new QueryObserver(queryClient, { + queryKey: communityKeys.server("cold"), + queryFn: ({ signal }) => { + reads.cold += 1 + return withConversationReadDeadline(signal, () => new Promise(() => {})) + }, + }), + new QueryObserver(queryClient, { + queryKey: communityKeys.server("cached"), + initialData: { id: "cached" }, + staleTime: Infinity, + queryFn: async () => { + reads.cached += 1 + if (reads.cached === 1) throw new Error("temporary cached failure") + return { id: "cached" } + }, + }), + new QueryObserver(queryClient, { + queryKey: communityKeys.server("healthy"), + initialData: { id: "healthy" }, + staleTime: Infinity, + queryFn: async () => { reads.healthy += 1; return { id: "healthy" } }, + }), + ] + const unsubscribes = observers.map((observer) => observer.subscribe(() => {})) + const failure = reconcileAccountReadState(queryClient, { surfaceMode: "non-inbox", targetRevision: 1 }) + .catch((error: unknown) => error) + await vi.advanceTimersByTimeAsync(15_000) + const error = await failure + expect((error as AggregateError).errors).toEqual(expect.arrayContaining([ + expect.any(ConversationReadTimeoutError), expect.objectContaining({ message: "temporary cached failure" }), + ])) + await vi.advanceTimersByTimeAsync(100) + expect(reads).toEqual({ cold: 1, cached: 2, healthy: 1 }) + expect(observers[0]!.getCurrentResult()).toMatchObject({ status: "error", fetchStatus: "idle" }) + expect(observers[1]!.getCurrentResult().status).toBe("success") + await vi.advanceTimersByTimeAsync(20_000) + expect(reads).toEqual({ cold: 1, cached: 2, healthy: 1 }) + unsubscribes.forEach((unsubscribe) => unsubscribe()) + disposeAccountReadStateReconciliation(queryClient) + vi.useRealTimers() + }) + + it.each(["native Retry", "new authoritative generation"])("recovers cold pending work through %s", async (recovery) => { + vi.useFakeTimers() + apiFetch.mockImplementation(async () => ({ revision: apiFetch.mock.calls.length, readStates: [] })) + let reads = 0 + const observer = new QueryObserver(queryClient, { + queryKey: communityKeys.server("cold"), + queryFn: ({ signal }) => { + reads += 1 + return reads === 1 + ? withConversationReadDeadline(signal, () => new Promise(() => {})) + : Promise.resolve({ id: "cold" }) + }, + }) + const unsubscribe = observer.subscribe(() => {}) + const failure = reconcileAccountReadState(queryClient, { surfaceMode: "non-inbox", targetRevision: 1 }) + .catch((error: unknown) => error) + await vi.advanceTimersByTimeAsync(15_000) + expect(await failure).toBeInstanceOf(AggregateError) + if (recovery === "native Retry") await observer.refetch({ cancelRefetch: false }) + await expect(reconcileAccountReadState(queryClient, { + surfaceMode: "non-inbox", targetRevision: recovery === "native Retry" ? 1 : 2, + })).resolves.toMatchObject({ revision: recovery === "native Retry" ? 1 : 2 }) + expect(reads).toBe(2) + expect(observer.getCurrentResult().status).toBe("success") + await vi.advanceTimersByTimeAsync(10_000) + expect(reads).toBe(2) + unsubscribe() + disposeAccountReadStateReconciliation(queryClient) + vi.useRealTimers() + }) + + it.each(["removed", "replaced", "inactive", "disposed", "account", "access"])("does not revive a %s retry target", async (retirement) => { + vi.useFakeTimers() + apiFetch.mockResolvedValue({ revision: 1, readStates: [] }) + let reads = 0 + const observer = new QueryObserver(queryClient, { + queryKey: communityKeys.server("cached"), + initialData: { id: "cached" }, + staleTime: Infinity, + queryFn: async () => { reads += 1; throw new Error("temporary failure") }, + }) + const unsubscribe = observer.subscribe(() => {}) + await expect(reconcileAccountReadState(queryClient, { surfaceMode: "non-inbox", targetRevision: 1 })) + .rejects.toThrow("read-state surface reconciliation failed") + let replacementUnsubscribe: (() => void) | undefined + const replacementRead = vi.fn(async () => ({ id: "replacement" })) + if (retirement === "disposed") disposeAccountReadStateReconciliation(queryClient) + else if (retirement === "inactive") unsubscribe() + else if (retirement === "account") createCommunityDbRegistry(queryClient, "viewer").runtime.ws.actions.activateProfileAccount("next-viewer") + else if (retirement === "access") createCommunityDbRegistry(queryClient, "viewer").runtime.ws.setState((state) => ({ ...state, accessEpoch: state.accessEpoch + 1 })) + else { + queryClient.removeQueries({ queryKey: communityKeys.server("cached"), exact: true }) + if (retirement === "replaced") { + const replacement = new QueryObserver(queryClient, { + queryKey: communityKeys.server("cached"), + initialData: { id: "replacement" }, + staleTime: Infinity, + queryFn: replacementRead, + }) + replacementUnsubscribe = replacement.subscribe(() => {}) + } + } + await vi.advanceTimersByTimeAsync(10_000) + expect(reads).toBe(1) + expect(replacementRead).not.toHaveBeenCalled() + if (retirement === "removed") expect(queryClient.getQueryState(communityKeys.server("cached"))).toBeUndefined() + if (retirement === "replaced") expect(queryClient.getQueryData(communityKeys.server("cached"))).toEqual({ id: "replacement" }) + if (retirement === "inactive") { + await expect(reconcileAccountReadState(queryClient, { surfaceMode: "non-inbox", targetRevision: 1 })) + .rejects.toThrow("read-state surface reconciliation failed") + expect(reads).toBe(1) + } + unsubscribe() + replacementUnsubscribe?.() + disposeAccountReadStateReconciliation(queryClient) + vi.useRealTimers() + }) + + it("joins an explicit Retry without cancelling its in-flight cached read", async () => { + vi.useFakeTimers() + apiFetch.mockResolvedValue({ revision: 1, readStates: [] }) + let reads = 0 + let release!: (data: { id: string }) => void + let retrySignal: AbortSignal | undefined + const observer = new QueryObserver(queryClient, { + queryKey: communityKeys.server("cached"), + initialData: { id: "cached" }, + staleTime: Infinity, + queryFn: ({ signal }) => { + reads += 1 + if (reads === 1) return Promise.reject(new Error("temporary background failure")) + retrySignal = signal + return new Promise<{ id: string }>((resolve) => { release = resolve }) + }, + }) + const unsubscribe = observer.subscribe(() => {}) + await expect(reconcileAccountReadState(queryClient, { surfaceMode: "non-inbox", targetRevision: 1 })) + .rejects.toThrow("read-state surface reconciliation failed") + const retry = observer.refetch({ cancelRefetch: false }) + await vi.advanceTimersByTimeAsync(100) + expect(reads).toBe(2) + expect(retrySignal?.aborted).toBe(false) + release({ id: "recovered" }) + await retry + await vi.advanceTimersByTimeAsync(100) + expect(observer.getCurrentResult()).toMatchObject({ status: "success", data: { id: "recovered" } }) + expect(reads).toBe(2) + unsubscribe() + disposeAccountReadStateReconciliation(queryClient) + vi.useRealTimers() + }) + + it("keeps access failure dirty without retrying it on a read-state revision", async () => { + vi.useFakeTimers() + apiFetch.mockImplementation(async () => ({ revision: apiFetch.mock.calls.length, readStates: [] })) + const denied = Object.assign(new Error("Forbidden"), { status: 403 }) + const read = vi.fn(async () => { throw denied }) + const observer = new QueryObserver(queryClient, { + queryKey: communityKeys.server("denied"), + initialData: { id: "denied" }, + staleTime: Infinity, + queryFn: read, + }) + const unsubscribe = observer.subscribe(() => {}) + await expect(reconcileAccountReadState(queryClient, { surfaceMode: "non-inbox", targetRevision: 1 })) + .rejects.toMatchObject({ errors: [denied] }) + await vi.advanceTimersByTimeAsync(10_000) + await expect(reconcileAccountReadState(queryClient, { surfaceMode: "non-inbox", targetRevision: 2 })) + .rejects.toMatchObject({ errors: [denied] }) + expect(read).toHaveBeenCalledOnce() + expect(queryClient.getQueryData(communityKeys.accountReadStateSnapshot())).toMatchObject({ revision: 2 }) + unsubscribe() + disposeAccountReadStateReconciliation(queryClient) + vi.useRealTimers() + }) }) diff --git a/src/web/src/hooks/community/community-ws/read-state-reconciliation.ts b/src/web/src/hooks/community/community-ws/read-state-reconciliation.ts index 08e282539..56df24e23 100644 --- a/src/web/src/hooks/community/community-ws/read-state-reconciliation.ts +++ b/src/web/src/hooks/community/community-ws/read-state-reconciliation.ts @@ -1,5 +1,5 @@ import { communityRequestOptions } from "@/lib/community/account-cache-lifecycle" -import { notifyManager, type QueryClient, type QueryKey } from "@tanstack/react-query" +import { notifyManager, type Query, type QueryClient, type QueryKey } from "@tanstack/react-query" import { apiFetch } from "@/lib/api/client" import { communityKeys, @@ -7,7 +7,8 @@ import { } from "@/lib/query-keys" import { projectReadCoordinatorSnapshot } from "@/hooks/community/read-coordinator-snapshot-projection" import { acceptAccountUnreadPrimarySnapshot } from "@/hooks/community/account-unread-projection" -import { captureCommunityLiveSnapshotToken, publishCommunityLiveSnapshot, type CommunityLiveSnapshotToken } from "@/lib/community-db/sync" +import { assertCommunityLiveSnapshotTokenCurrent, captureCommunityLiveSnapshotToken, publishCommunityLiveSnapshot, type CommunityLiveSnapshotToken } from "@/lib/community-db/sync" +import { isConversationAccessError } from "@/lib/community/conversation-read" type AccountReadState = { channelId: string @@ -26,6 +27,19 @@ export type ReadStateEnvelope = { inboxChanged: true } +type ServerSurfaceTarget = { + queryKey: QueryKey + query: Query | undefined + failed: boolean + error: unknown +} + +type ServerSurfaceWork = { + generation: number + targets: ServerSurfaceTarget[] + owner: CommunityLiveSnapshotToken +} + type ReconciliationState = { highestPendingTargetRevision: number | null snapshotRequestedGeneration: number @@ -37,6 +51,7 @@ type ReconciliationState = { snapshotWorker: Promise | null inboxDmsWorker: Promise | null serverWorker: Promise | null + serverSurfaceWork: ServerSurfaceWork | null snapshotRetryTimer: ReturnType | null snapshotRetryDelayMs: number inboxDmsRetryTimer: ReturnType | null @@ -145,26 +160,62 @@ async function invalidateInboxDmsSurfaces(queryClient: QueryClient) { } } -async function invalidateServerSurfaces(queryClient: QueryClient) { - const serverIds = new Set() +function captureServerSurfaceWork(queryClient: QueryClient, generation: number): ServerSurfaceWork { + const keys: QueryKey[] = [communityKeys.servers()] for (const query of queryClient.getQueryCache().getAll()) { const key = query.queryKey - if (isCommunityServerDetailQueryKey(key)) serverIds.add(key[2]) + if (isCommunityServerDetailQueryKey(key)) keys.push(key) } + return { generation, owner: captureCommunityLiveSnapshotToken(queryClient), targets: keys.map((queryKey) => { + const query = queryClient.getQueryCache().find({ queryKey, exact: true }) + return { queryKey, query, + failed: isConversationAccessError(query?.state.error), + error: query?.state.error, + } + }) } +} + +function isOriginalServerSurface(queryClient: QueryClient, target: ServerSurfaceTarget) { + return target.query !== undefined + && queryClient.getQueryCache().get(target.query.queryHash) === target.query +} + +function canRetryServerSurface(queryClient: QueryClient, target: ServerSurfaceTarget) { + const query = target.query + return isOriginalServerSurface(queryClient, target) && query !== undefined + && query.isActive() + && !isConversationAccessError(query.state.error ?? target.error) + && !(query.state.data === undefined && query.state.status === "error") +} + +async function invalidateServerSurfaces(queryClient: QueryClient, work: ServerSurfaceWork) { + assertCommunityLiveSnapshotTokenCurrent(queryClient, work.owner, undefined) + work.targets = work.targets.filter((target) => !target.failed || ( + isOriginalServerSurface(queryClient, target) + && target.query?.state.status !== "success" + )) + const attempted = work.targets.filter((target) => !target.failed || canRetryServerSurface(queryClient, target)) const refetchOptions = { throwOnError: true, cancelRefetch: true } - const settled = await Promise.allSettled([ - queryClient.invalidateQueries( - { queryKey: communityKeys.servers(), exact: true, refetchType: "active" }, - refetchOptions, - ), - ...[...serverIds].map((serverId) => queryClient.invalidateQueries({ - queryKey: communityKeys.server(serverId), + const settled = await Promise.allSettled(attempted.map((target) => { + assertCommunityLiveSnapshotTokenCurrent(queryClient, work.owner, undefined) + return queryClient.invalidateQueries({ + queryKey: target.queryKey, exact: true, refetchType: "active", - }, refetchOptions)), - ]) - if (settled.some((result) => result.status === "rejected")) { - throw new Error("read-state server reconciliation failed") + predicate: (query) => query === target.query, + }, target.failed ? { ...refetchOptions, cancelRefetch: false } : refetchOptions) + })) + settled.forEach((result, index) => { + const target = attempted[index]! + if (result.status === "rejected") { + target.failed = true + target.error = result.reason + } else { + work.targets = work.targets.filter((pending) => pending !== target) + } + }) + if (work.targets.length > 0) { + throw new AggregateError(work.targets.map((target) => target.error), "read-state surface reconciliation failed") } } @@ -264,6 +315,7 @@ function getReconciliationState(queryClient: QueryClient) { snapshotWorker: null, inboxDmsWorker: null, serverWorker: null, + serverSurfaceWork: null, snapshotRetryTimer: null, snapshotRetryDelayMs: INITIAL_RETRY_DELAY_MS, inboxDmsRetryTimer: null, @@ -476,11 +528,20 @@ async function runServerWorker(queryClient: QueryClient, state: ReconciliationSt await ensureSnapshotWorker(queryClient, state) assertReconciliationActive(queryClient, state, epoch) const surfaceGeneration = state.serverRequestedGeneration + if (!state.serverSurfaceWork || state.serverSurfaceWork.generation < surfaceGeneration) { + state.serverSurfaceWork = captureServerSurfaceWork(queryClient, surfaceGeneration) + } + const work = state.serverSurfaceWork try { - await invalidateServerSurfaces(queryClient) - } catch { - scheduleReconciliationRetry(queryClient, state, "non-inbox") - throw new Error("read-state surface reconciliation failed") + await invalidateServerSurfaces(queryClient, work) + } catch (error) { + assertReconciliationActive(queryClient, state, epoch) + assertCommunityLiveSnapshotTokenCurrent(queryClient, work.owner, undefined) + if (surfaceGeneration < state.serverRequestedGeneration) continue + if (work.targets.some((target) => canRetryServerSurface(queryClient, target))) { + scheduleReconciliationRetry(queryClient, state, "non-inbox") + } + throw error } assertReconciliationActive(queryClient, state, epoch) state.serverCompletedGeneration = Math.max( @@ -488,6 +549,7 @@ async function runServerWorker(queryClient: QueryClient, state: ReconciliationSt surfaceGeneration, ) state.serverRetryDelayMs = INITIAL_RETRY_DELAY_MS + state.serverSurfaceWork = null } } @@ -542,6 +604,7 @@ export function disposeAccountReadStateReconciliation(queryClient: QueryClient) clearReconciliationRetry(state, "non-inbox") state.requestController?.abort() state.requestController = null + state.serverSurfaceWork = null } export function projectReadStateEnvelope( diff --git a/src/web/src/hooks/community/use-channel-message-feed.dom.test.ts b/src/web/src/hooks/community/use-channel-message-feed.dom.test.ts index 7fb247ca7..45bc56d68 100644 --- a/src/web/src/hooks/community/use-channel-message-feed.dom.test.ts +++ b/src/web/src/hooks/community/use-channel-message-feed.dom.test.ts @@ -1,6 +1,6 @@ import { createElement } from "react" import { beforeEach, describe, expect, it, vi } from "vitest" -import { render } from "@/test/react-dom-harness" +import { act, render } from "@/test/react-dom-harness" import { useChannelMessageFeed } from "./use-channel-message-feed" const mocks = vi.hoisted(() => ({ @@ -10,6 +10,8 @@ const mocks = vi.hoisted(() => ({ lastReadSeq: 2, } as { lastReadMessageId: string | null; lastReadSeq: number } | null, isFetching: false, + error: null as unknown, + retry: vi.fn(), }, messages: { messages: [ @@ -23,6 +25,7 @@ const mocks = vi.hoisted(() => ({ isError: false, hasMoreNewer: false, refetch: vi.fn(), + error: null as unknown, }, useMessages: vi.fn(), watermark: vi.fn(), @@ -53,7 +56,7 @@ vi.mock("@/lib/community-db/projections", async (importOriginal) => ({ ...await useReadStateProjection: () => mocks.canonicalReadSnapshot, })) -function Capture() { +function Capture({ onResult }: { onResult?: (result: ReturnType) => void }) { const result = useChannelMessageFeed({ channelId: "channel", serverId: "server", @@ -61,6 +64,7 @@ function Capture() { isChildChannel: false, anchorMessageId: null, }) + onResult?.(result) return createElement("output", { "data-divider": result.newDividerBefore, "data-anchor-found": result.anchorInCache, @@ -75,6 +79,10 @@ describe("useChannelMessageFeed", () => { lastReadSeq: 2, } mocks.readState.isFetching = false + mocks.readState.error = null + mocks.readState.retry.mockReset() + mocks.messages.error = null + mocks.messages.refetch.mockReset() mocks.messages.messages = [ { id: "self", authorId: "viewer" }, { id: "authoritative-anchor", authorId: "viewer" }, @@ -99,6 +107,30 @@ describe("useChannelMessageFeed", () => { renderer.unmount() }) + it("prioritizes a definitive message denial over a simultaneous transient read failure", () => { + const denied = Object.assign(new Error("Forbidden"), { status: 403 }) + mocks.readState.error = new Error("Read unavailable") + mocks.messages.error = denied + let latest!: ReturnType + render(createElement(Capture, { onResult: (result) => { latest = result } })) + expect(latest.initialLoadError).toBe(denied) + act(() => latest.retryInitialLoad()) + expect(mocks.messages.refetch).toHaveBeenCalledWith({ cancelRefetch: false }) + expect(mocks.readState.retry).not.toHaveBeenCalled() + }) + + it("retries a read snapshot failure without refetching usable cached messages", () => { + const failure = new Error("Read snapshot unavailable") + mocks.readState.error = failure + let latest!: ReturnType + render(createElement(Capture, { onResult: (result) => { latest = result } })) + expect(latest.initialLoadError).toBe(failure) + expect(latest.messages).toEqual(mocks.messages.messages) + act(() => latest.retryInitialLoad()) + expect(mocks.readState.retry).toHaveBeenCalledOnce() + expect(mocks.messages.refetch).not.toHaveBeenCalled() + }) + it("seeds the frozen mount snapshot from the canonical read projection", () => { mocks.canonicalReadSnapshot = { lastReadMessageId: "authoritative-anchor", diff --git a/src/web/src/hooks/community/use-channel-message-feed.ts b/src/web/src/hooks/community/use-channel-message-feed.ts index 91c88f053..6667ab185 100644 --- a/src/web/src/hooks/community/use-channel-message-feed.ts +++ b/src/web/src/hooks/community/use-channel-message-feed.ts @@ -2,6 +2,7 @@ import { useAtom, useCreateAtom } from "@tanstack/react-store"; import { useMemo } from "react" +import { isConversationAccessError } from "@/lib/community/conversation-read" import { useChannelReadStateSnapshot } from "./use-channel-read-state" import { useChannelWatermark } from "./use-channel-watermark" import { useMessages } from "./use-messages" @@ -56,7 +57,9 @@ export function useChannelMessageFeed({ channelId, messages: messagesQuery.messages, scrollRootEl, - snapshotStatus: readState.isFetching + snapshotStatus: isConversationAccessError(readState.error) + ? "error" + : readState.isFetching ? "pending" : readSnapshot ? "ready" @@ -76,11 +79,19 @@ export function useChannelMessageFeed({ }, [messagesQuery.latestSeq, readSnapshot]) const threadsQuery = useThreads(channelId) const pinsQuery = usePins(channelId) + const initialLoadError = isConversationAccessError(messagesQuery.error) + ? messagesQuery.error : readState.error ?? messagesQuery.error return { ...messagesQuery, readSnapshot, readSnapshotFetching: readState.isFetching, + initialLoadError, + retryingInitialLoad: readState.retrying || messagesQuery.isFetching, + retryInitialLoad: () => { + if (initialLoadError && initialLoadError === messagesQuery.error) void messagesQuery.refetch({ cancelRefetch: false }) + else readState.retry() + }, newDividerBefore, anchorInCache: anchorFound, unreadCount, diff --git a/src/web/src/hooks/community/use-channel-metadata.dom.test.ts b/src/web/src/hooks/community/use-channel-metadata.dom.test.ts index 9760f2f4b..638b4bb9c 100644 --- a/src/web/src/hooks/community/use-channel-metadata.dom.test.ts +++ b/src/web/src/hooks/community/use-channel-metadata.dom.test.ts @@ -10,6 +10,7 @@ import { channelMetadataOptions, captureChannelMetadataToken, isChannelMetadataT import { useChannelMetadata } from "./use-channel-metadata" import { useDmRouteVerification } from "./use-dm-route-verification" import { useDmReadStateSnapshot } from "./use-dm-read-state" +import { startConversationNavigationWarmup } from "@/lib/community/conversation-navigation-warmup" const apiFetch = vi.hoisted(() => vi.fn()) vi.mock("@/lib/api/client", () => ({ apiFetch: (...args: unknown[]) => apiFetch(...args) })) @@ -24,8 +25,9 @@ const registries: CommunityDbRegistry[] = [] const unregisters: Array<() => void> = [] function deferred() { let resolve!: (value: T) => void - const promise = new Promise((done) => { resolve = done }) - return { promise, resolve } + let reject!: (error: unknown) => void + const promise = new Promise((done, fail) => { resolve = done; reject = fail }) + return { promise, resolve, reject } } async function fixture(client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } } })) { const registry = createCommunityDbRegistry(client, "viewer") @@ -124,6 +126,128 @@ describe("shared Channel resource and canonical DM publication", () => { }) describe("DM history permission stays separate from metadata identity", () => { + it("keeps an early warm-read receipt unverified and stale until the mounted metadata request succeeds", async () => { + const { client, registry, wrapper } = await fixture() + ingestDms(registry, dms) + const detail = deferred() + apiFetch.mockImplementation((path: string) => path.endsWith("/messages") + ? Promise.resolve({ messages: [], hasMore: false, surfaceReceipt: { channelId: metadata.id, surfaceKind: "dm" } }) + : path.endsWith("/read-state") ? Promise.resolve(readState) : detail.promise) + act(() => { startConversationNavigationWarmup(client, { + href: `/c/me/${metadata.id}`, viewerId: "viewer", channelId: metadata.id, scopeKind: "dm", + }, registry.runtime.ws.get().accessEpoch) }) + await waitFor(() => expect(client.getQueryData<{ historyVerification?: unknown }>(metadataKey)?.historyVerification).toBeDefined()) + expect(client.getQueryData<{ verification?: unknown }>(metadataKey)?.verification).toBeUndefined() + expect(client.getQueryState(metadataKey)?.isInvalidated).toBe(true) + const originalResource = client.getQueryCache().find({ queryKey: metadataKey, exact: true }) + const route = renderHook(() => useChannelMetadata(null, metadata.id), { wrapper }) + expect(route.result.current.isVerified).toBe(false) + await waitFor(() => expect(apiFetch.mock.calls.filter(([path]) => path === `/api/community/channels/${metadata.id}`)).toHaveLength(1)) + await act(async () => detail.resolve(metadata)) + await waitFor(() => expect(route.result.current.isVerified).toBe(true)) + expect(isChannelMetadataTokenCurrent(route.result.current.data!.historyVerification!)).toBe(true) + expect(client.getQueryCache().find({ queryKey: metadataKey, exact: true })).toBe(originalResource) + expect(apiFetch.mock.calls.filter(([path]) => path.endsWith("/read-state"))).toHaveLength(1) + route.unmount() + }) + + it.each(["absent", "pending"] as const)("retains a read-first Inbox receipt on the original %s Channel resource until metadata settles", async (order) => { + const { client, registry, wrapper } = await fixture() + ingestDms(registry, dms) + if (order === "pending") client.getQueryCache().build(client, client.defaultQueryOptions(channelMetadataOptions(client, null, metadata.id))) + expect(client.getQueryData(metadataKey)).toBeUndefined() + expect(!!client.getQueryCache().find({ queryKey: metadataKey, exact: true })).toBe(order === "pending") + const read = deferred() + const detail = deferred() + apiFetch.mockImplementation((path: string) => path.endsWith("/messages") + ? Promise.resolve({ messages: [], hasMore: false, surfaceReceipt: { channelId: metadata.id, surfaceKind: "dm" } }) + : path.endsWith("/read-state") ? read.promise : detail.promise) + act(() => { startConversationNavigationWarmup(client, { + href: `/c/me/${metadata.id}`, viewerId: "viewer", channelId: metadata.id, scopeKind: "dm", + }, registry.runtime.ws.get().accessEpoch) }) + await waitFor(() => expect(apiFetch.mock.calls.filter(([path]) => path.endsWith("/read-state"))).toHaveLength(1)) + const route = renderHook(() => ({ metadata: useChannelMetadata(null, metadata.id), read: useDmReadStateSnapshot(metadata.id) }), { wrapper }) + await waitFor(() => expect(apiFetch.mock.calls.filter(([path]) => path === `/api/community/channels/${metadata.id}`)).toHaveLength(1)) + const originalResource = client.getQueryCache().find({ queryKey: metadataKey, exact: true }) + await act(async () => read.resolve(readState)) + await waitFor(() => expect(route.result.current.read.snapshot).toEqual(readState)) + expect(route.result.current.metadata.isVerified).toBe(false) + expect(client.getQueryData<{ historyVerification?: unknown }>(metadataKey)?.historyVerification).toBeDefined() + await act(async () => detail.resolve(metadata)) + await waitFor(() => expect(route.result.current.metadata.isVerified).toBe(true)) + expect(isChannelMetadataTokenCurrent(route.result.current.metadata.data!.historyVerification!)).toBe(true) + expect(client.getQueryCache().find({ queryKey: metadataKey, exact: true })).toBe(originalResource) + expect(apiFetch.mock.calls.filter(([path]) => path.endsWith("/read-state"))).toHaveLength(1) + expect(apiFetch.mock.calls.filter(([path]) => path === `/api/community/channels/${metadata.id}`)).toHaveLength(1) + route.unmount() + }) + + it("qualifies the same pending Inbox DM read joined by the mounted route", async () => { + const { client, registry, wrapper } = await fixture() + publishDms(registry) + const request = deferred() + apiFetch.mockImplementation((path: string) => path.endsWith("/messages") + ? Promise.resolve({ messages: [], hasMore: false, surfaceReceipt: { channelId: metadata.id, surfaceKind: "dm" } }) + : request.promise) + act(() => { startConversationNavigationWarmup(client, { + href: `/c/me/${metadata.id}`, viewerId: "viewer", channelId: metadata.id, scopeKind: "dm", + }, registry.runtime.ws.get().accessEpoch) }) + await waitFor(() => expect(apiFetch.mock.calls.filter(([path]) => path.endsWith("/read-state"))).toHaveLength(1)) + const route = renderHook(() => ({ metadata: useChannelMetadata(null, metadata.id), read: useDmReadStateSnapshot(metadata.id) }), { wrapper }) + expect(route.result.current.metadata.isVerified).toBe(true) + expect(route.result.current.metadata.data?.historyVerification).toBeUndefined() + await act(async () => request.resolve(readState)) + await waitFor(() => expect(route.result.current.read.snapshot).toEqual(readState)) + expect(route.result.current.metadata.data?.historyVerification).toBeDefined() + expect(isChannelMetadataTokenCurrent(route.result.current.metadata.data!.historyVerification!)).toBe(true) + expect(apiFetch.mock.calls.filter(([path]) => path.endsWith("/read-state"))).toHaveLength(1) + route.unmount() + }) + + it.each((["published", "absent", "pending"] as const).flatMap((order) => + (["denial403", "denial404", "account", "access", "replacement"] as const).map((race) => ({ order, race })), + ))("cannot grant $order Channel history from the shared Inbox read after $race", async ({ order, race }) => { + const { client, registry, wrapper } = await fixture() + if (order === "published") publishDms(registry) + else { + ingestDms(registry, dms) + if (order === "pending") client.getQueryCache().build(client, client.defaultQueryOptions(channelMetadataOptions(client, null, metadata.id))) + } + const request = deferred() + if (order === "published" && race.startsWith("denial")) { + client.setQueryData(metadataKey, (previous: object | undefined) => ({ ...previous, historyVerification: captureChannelMetadataToken(client, metadata.id) })) + } + apiFetch.mockImplementation((path: string) => path.endsWith("/messages") + ? Promise.resolve({ messages: [], hasMore: false, surfaceReceipt: { channelId: metadata.id, surfaceKind: "dm" } }) + : request.promise) + act(() => { startConversationNavigationWarmup(client, { + href: `/c/me/${metadata.id}`, viewerId: "viewer", channelId: metadata.id, scopeKind: "dm", + }, registry.runtime.ws.get().accessEpoch) }) + await waitFor(() => expect(apiFetch.mock.calls.filter(([path]) => path.endsWith("/read-state"))).toHaveLength(1)) + const route = renderHook(() => useDmReadStateSnapshot(metadata.id), { wrapper }) + act(() => { + if (race === "account") { + registry.runtime.ws.actions.activateProfileAccount("other") + registry.runtime.ws.actions.activateProfileAccount("viewer") + } else if (race === "access") { + registry.runtime.ws.setState((state) => ({ ...state, accessEpoch: state.accessEpoch + 1 })) + } else if (race === "replacement") { + client.removeQueries({ queryKey: metadataKey, exact: true }) + publishDms(registry) + } + }) + await act(async () => { + if (race.startsWith("denial")) request.reject(Object.assign(new Error("denied"), { status: Number(race.slice(6)) })) + else request.resolve(readState) + }) + if (race === "replacement") await waitFor(() => expect(route.result.current.snapshot).toEqual(readState)) + else await waitFor(() => expect(route.result.current.error).toMatchObject(race.startsWith("denial") + ? { status: Number(race.slice(6)) } : { name: "AbortError" })) + expect(client.getQueryData<{ historyVerification?: unknown }>(metadataKey)?.historyVerification).toBeUndefined() + expect(apiFetch.mock.calls.filter(([path]) => path.endsWith("/read-state"))).toHaveLength(1) + route.unmount() + }) + it("keeps a transient read error local and grants history after explicit Retry succeeds", async () => { const { registry, wrapper } = await fixture(new QueryClient({ defaultOptions: { queries: { retryDelay: 0, gcTime: Infinity } }, diff --git a/src/web/src/hooks/community/use-channel-read-state.dom.test.ts b/src/web/src/hooks/community/use-channel-read-state.dom.test.ts index e5b2e50eb..aff1095df 100644 --- a/src/web/src/hooks/community/use-channel-read-state.dom.test.ts +++ b/src/web/src/hooks/community/use-channel-read-state.dom.test.ts @@ -1,9 +1,10 @@ import { createElement, type PropsWithChildren } from "react" -import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { QueryClient, QueryClientProvider, onlineManager } from "@tanstack/react-query" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { act, renderHook, waitFor } from "@/test/react-dom-harness" import { createCommunityDbRegistry, type CommunityDbRegistry } from "@/lib/community-db/collections" import { CommunityDbProvider } from "@/lib/community-db/projections" +import { CONVERSATION_READ_TIMEOUT_MS, ConversationReadTimeoutError } from "@/lib/community/conversation-read" import { communityKeys } from "@/lib/query-keys" import { useChannelReadStateSnapshot, type ChannelReadStateSnapshot } from "./use-channel-read-state" @@ -22,6 +23,7 @@ beforeEach(async () => { afterEach(async () => { await act(async () => { await client.cancelQueries(); await registry.cleanup(); client.clear() + vi.useRealTimers(); onlineManager.setOnline(true) }) }) function Owner({ children }: PropsWithChildren) { return createElement(QueryClientProvider, { client }, createElement(CommunityDbProvider, { registry }, children)) } @@ -128,4 +130,34 @@ describe("native channel read-state snapshot", () => { await waitFor(() => expect(second.result.current.snapshot).toEqual(fresh)) expect(api).toHaveBeenCalledTimes(2) }) + it("turns a held cold read into a manual retryable timeout and fences its late result", async () => { + vi.useFakeTimers() + const pending = held(), rendered = mount() + await act(async () => { await vi.advanceTimersByTimeAsync(0) }) + expect(api).toHaveBeenCalledOnce() + const signal = api.mock.calls[0][1].signal as AbortSignal + await act(async () => { await vi.advanceTimersByTimeAsync(CONVERSATION_READ_TIMEOUT_MS + 1) }) + expect(rendered.result.current.error).toBeInstanceOf(ConversationReadTimeoutError) + expect(rendered.result.current.snapshot).toBeNull() + expect(rendered.result.current.isFetching).toBe(false) + expect(signal.aborted).toBe(true) + await act(async () => { pending.resolve(original); await vi.advanceTimersByTimeAsync(0) }) + expect(rendered.result.current.snapshot).toBeNull() + api.mockResolvedValueOnce(fresh) + await act(async () => { rendered.result.current.retry(); await vi.advanceTimersByTimeAsync(1) }) + expect(rendered.result.current.snapshot).toEqual(fresh) + expect(api).toHaveBeenCalledTimes(2) + }) + + it("runs the bounded first read even when Query's online manager is offline", async () => { + vi.useFakeTimers() + onlineManager.setOnline(false) + held() + const rendered = mount() + await act(async () => { await vi.advanceTimersByTimeAsync(CONVERSATION_READ_TIMEOUT_MS + 1) }) + expect(api).toHaveBeenCalledOnce() + expect(rendered.result.current.error).toBeInstanceOf(ConversationReadTimeoutError) + expect(rendered.result.current.isFetching).toBe(false) + }) + }) diff --git a/src/web/src/hooks/community/use-channel-read-state.ts b/src/web/src/hooks/community/use-channel-read-state.ts index 1d64c136a..f94bdbdbe 100644 --- a/src/web/src/hooks/community/use-channel-read-state.ts +++ b/src/web/src/hooks/community/use-channel-read-state.ts @@ -2,9 +2,10 @@ import { useLayoutEffect, useMemo } from "react" import { createStore, useSelector } from "@tanstack/react-store" -import { useQuery, useQueryClient } from "@tanstack/react-query" +import { useQuery, useQueryClient, type QueryFunctionContext } from "@tanstack/react-query" import { communityRequestOptions } from "@/lib/community/account-cache-lifecycle" -import { captureChannelMetadataToken, isChannelMetadataTokenCurrent, type ChannelMetadataResource } from "./channel-metadata" +import { channelMetadataOptions, captureChannelMetadataToken, isChannelMetadataTokenCurrent, type ChannelMetadataResource } from "./channel-metadata" +import { retryConversationRead, withConversationReadDeadline } from "@/lib/community/conversation-read" import { apiFetch } from "@/lib/api/client" import { communityKeys } from "@/lib/query-keys" @@ -23,6 +24,47 @@ export type ChannelReadStateSnapshot = { lastReadSeq: number } +export function channelReadStateSnapshotQueryFn( + channelId: string, + kind: "channel" | "dm", + options?: { assertNavigationCurrent: () => void; waitForRegistryReady: boolean }, +) { + return async ({ client, signal }: QueryFunctionContext): Promise => { + const token = captureChannelMetadataToken(client, channelId) + const metadataKey = communityKeys.channelMeta(null, channelId) + const assert = () => { + if (signal.aborted || !isChannelMetadataTokenCurrent(token)) throw new DOMException("Stale read-state owner", "AbortError") + options?.assertNavigationCurrent() + } + assert() + const metadataQuery = kind === "dm" + ? client.getQueryCache().build(client, client.defaultQueryOptions(channelMetadataOptions(client, null, channelId))) + : undefined + const updateHistory = (historyVerification?: ChannelMetadataResource["historyVerification"]) => { + if (!metadataQuery || !Object.is(client.getQueryCache().find({ queryKey: metadataKey, exact: true }), metadataQuery)) return + const metadata = client.setQueryData(metadataKey, (previous) => previous + ? { ...previous, historyVerification } + : historyVerification ? { id: channelId, verifiedEpoch: -1, historyVerification } : undefined) + if (metadata && !metadata.verification) metadataQuery.invalidate() + } + try { + const snapshot = await withConversationReadDeadline(signal, async (readSignal) => { + if (options?.waitForRegistryReady) await token.registry!.ready + readSignal.throwIfAborted() + assert() + return apiFetch(`/api/community/channels/${channelId}/read-state`, communityRequestOptions(client, token, readSignal, assert)) + }) + assert() + updateHistory(token) + return snapshot + } catch (error) { + assert() + if (typeof error === "object" && error !== null && "status" in error && [403, 404].includes(Number(error.status))) updateHistory() + throw error + } + } +} + /** * Once-per-mount snapshot of the viewer's read pointer for a channel. * @@ -62,25 +104,7 @@ export function useChannelReadStateSnapshot( queryKey: channelId ? kind === "dm" ? communityKeys.dmReadStateSnapshot(channelId) : communityKeys.channelReadStateSnapshot(channelId) : ["community", kind, "__none__", "read-state-snapshot"], - queryFn: async ({ client, signal }) => { - const token = captureChannelMetadataToken(client, channelId!) - const metadataKey = communityKeys.channelMeta(null, channelId!) - const metadataQuery = kind === "dm" ? client.getQueryCache().find({ queryKey: metadataKey, exact: true }) : undefined - const assert = () => { if (signal.aborted || !isChannelMetadataTokenCurrent(token)) throw new DOMException("Stale read-state owner", "AbortError") } - const updateHistory = (historyVerification?: ChannelMetadataResource["historyVerification"]) => { - if (metadataQuery && client.getQueryCache().find({ queryKey: metadataKey, exact: true }) === metadataQuery) client.setQueryData(metadataKey, (metadata) => metadata ? { ...metadata, historyVerification } : metadata) - } - try { - const snapshot = await apiFetch(`/api/community/channels/${channelId}/read-state`, communityRequestOptions(client, token, signal, assert)) - assert() - updateHistory(token) - return snapshot - } catch (error) { - assert() - if (typeof error === "object" && error !== null && "status" in error && [403, 404].includes(Number(error.status))) updateHistory() - throw error - } - }, + queryFn: channelReadStateSnapshotQueryFn(channelId ?? "__none__", kind), enabled: !!channelId, subscribed: !!channelId, staleTime: Infinity, @@ -96,7 +120,8 @@ export function useChannelReadStateSnapshot( refetchOnReconnect: false, // Snapshot is one-shot; even if TanStack retries a failed fetch, the // Store latches only the FIRST resolved value we see. - retry: kind === "dm" ? (failureCount, error) => error.name !== "AbortError" && !("status" in error && [403, 404].includes(Number(error.status))) && failureCount < 1 : 1, + retry: retryConversationRead, + networkMode: "always", }) useLayoutEffect(() => { diff --git a/src/web/src/hooks/community/use-channel-route-model.retry.dom.test.ts b/src/web/src/hooks/community/use-channel-route-model.retry.dom.test.ts index 2046edf0f..4b6def97f 100644 --- a/src/web/src/hooks/community/use-channel-route-model.retry.dom.test.ts +++ b/src/web/src/hooks/community/use-channel-route-model.retry.dom.test.ts @@ -10,14 +10,14 @@ import { communityKeys } from "@/lib/query-keys" import { useCommunityStore } from "@/stores/community" import { useCommunityWsStore } from "@/stores/community/ws" -const mocks = vi.hoisted(() => ({ apiFetch: vi.fn(), replace: vi.fn(), toast: vi.fn() })) +const mocks = vi.hoisted(() => ({ apiFetch: vi.fn(), replace: vi.fn(), toast: vi.fn(), + server: { server: { id: "server-1", categories: [{ channels: [{ id: "parent-1", name: "parent", type: "text" }] }] } as { id: string; categories: { channels: { id: string; name: string; type: string }[] }[] } | null, + isError: false, isFetching: false, refetch: vi.fn(() => Promise.resolve()) }, +})) vi.mock("@/lib/api/client", () => ({ apiFetch: mocks.apiFetch, toastApiError: mocks.toast })) vi.mock("next/navigation", () => ({ useRouter: () => ({ replace: mocks.replace }) })) vi.mock("./use-servers", () => ({ - useServer: () => ({ server: { - id: "server-1", - categories: [{ channels: [{ id: "parent-1", name: "parent", type: "text" }] }], - } }), + useServer: () => mocks.server, })) vi.mock("./use-community-ws", () => ({ communityWsSubscribe: vi.fn(), communityWsUnsubscribe: vi.fn() })) vi.mock("./use-forum-sidebar-threads", () => ({ @@ -69,6 +69,8 @@ function payload(id = "post-1") { beforeEach(async () => { mocks.apiFetch.mockReset() + Object.assign(mocks.server, { server: { id: "server-1", categories: [{ channels: [{ id: "parent-1", name: "parent", type: "text" }] }] }, isError: false, isFetching: false }) + mocks.server.refetch.mockReset().mockResolvedValue(undefined) mocks.replace.mockClear() mocks.toast.mockClear() client = (await createCommunityQueryOwner("viewer-1", { defaultOptions: { queries: { retry: 1, retryDelay: 0, gcTime: Infinity } } })).client @@ -80,6 +82,28 @@ afterEach(async () => { }) describe("unresolved metadata terminal error and retry", () => { + it("keeps a missing server dependency retryable until its own read settles", async () => { + mocks.apiFetch.mockResolvedValue(payload()) + Object.assign(mocks.server, { server: null, isError: true }) + await mount() + await until(() => current.serverError) + expect(current.routeLifecycle).toBe("terminal-error") + let release!: () => void + mocks.server.refetch.mockImplementationOnce(() => new Promise((resolve) => { release = resolve })) + let request!: Promise + await act(async () => { request = current.retryServer(); void current.retryServer() }) + expect(mocks.server.refetch).toHaveBeenCalledOnce() + Object.assign(mocks.server, { isError: false, isFetching: true }) + renderer!.rerender(tree()) + expect(current.serverError).toBe(true) + expect(current.retryingServer).toBe(true) + await act(async () => { release(); await request }) + Object.assign(mocks.server, { server: { id: "server-1", categories: [{ channels: [{ id: "parent-1", name: "parent", type: "text" }] }] }, isFetching: false }) + renderer!.rerender(tree()) + await until(() => current.routeHydrated) + expect(current.serverError).toBe(false) + }) + it("waits for the original automatic retries before showing a persistent error", async () => { const first = deferred() const second = deferred() diff --git a/src/web/src/hooks/community/use-channel-route-model.ts b/src/web/src/hooks/community/use-channel-route-model.ts index 282e8c12f..eb0a3164f 100644 --- a/src/web/src/hooks/community/use-channel-route-model.ts +++ b/src/web/src/hooks/community/use-channel-route-model.ts @@ -76,7 +76,8 @@ export function useChannelRouteModel( const queryClient = useQueryClient() const runtime = useCommunityRuntime() const communityDb = useOptionalCommunityDbRegistry() - const { server } = useServer(serverId) + const serverQuery = useServer(serverId) + const { server, isFetching: fetchingServer, refetch: refetchServer } = serverQuery const dbChannel = useRouteChannelProjection(channelId) const accessEpoch = useCommunityWsStore((state) => state.accessEpoch) const topLevelChannel = server?.categories @@ -92,6 +93,21 @@ export function useChannelRouteModel( activityAt: metaQuery.data.lastMessageAt ?? metaQuery.data.createdAt } : null, [isChild, metaQuery.data, metaQuery.isVerified, serverId]) const retryScope = JSON.stringify([accountId, serverId, channelId, accessEpoch]) + const [serverRetryAttempt, setServerRetryAttempt] = useAtom(useCreateAtom<{ scope: string } | null>(null)) + const serverRetryRef = useRef<{ scope: string } | null>(null) + const retryingServer = serverRetryAttempt?.scope === retryScope + const serverError = !server?.categories && (serverQuery.isError || retryingServer) + const retryServer = useCallback(async () => { + if (!serverError || fetchingServer || serverRetryRef.current?.scope === retryScope) return + const attempt = { scope: retryScope } + serverRetryRef.current = attempt + setServerRetryAttempt(attempt) + try { await refetchServer({ cancelRefetch: false }) } + finally { + if (serverRetryRef.current === attempt) serverRetryRef.current = null + setServerRetryAttempt((current) => current === attempt ? null : current) + } + }, [retryScope, serverError, fetchingServer, refetchServer, setServerRetryAttempt]) const exitScope = JSON.stringify([accountId, serverId, channelId]) const [exitedMetadata, setExitedMetadata] = useAtom(useCreateAtom<{ owner: typeof communityDb; scope: string } | null>(null)) const freshMetadata = metaQuery.isVerified && !metaQuery.data?.archived @@ -137,7 +153,7 @@ export function useChannelRouteModel( [channelId, isChild, metaQuery.isVerified, renderableChannelMeta, server], ) const routeLifecycle = !server?.categories - ? "pending" as const + ? serverError ? "terminal-error" as const : "pending" as const : !isChild && metaQuery.isVerified ? "ready" as const : metadataExit || (metaQuery.isError && !metaQuery.isVerified) @@ -205,6 +221,9 @@ export function useChannelRouteModel( routeLifecycle, skeletonSubtype, metadataError, + serverError, + retryingServer, + retryServer, retryingMetadata, retryMetadata, } diff --git a/src/web/src/hooks/community/use-forum-feed.dom.test.ts b/src/web/src/hooks/community/use-forum-feed.dom.test.ts index 54e67e196..941ec9b16 100644 --- a/src/web/src/hooks/community/use-forum-feed.dom.test.ts +++ b/src/web/src/hooks/community/use-forum-feed.dom.test.ts @@ -8,6 +8,7 @@ import { createCommunityQueryOwner } from "@/test/community-query-owner" import { getCommunityDbRegistry } from "@/lib/community-db/collections" import { getCanonicalCommunityMessages, getCanonicalCommunityChannels } from "@/lib/community-db/sync" import { forumFeedWindow, type ForumFeedTransportPage } from "./forum-feed-window" +import { CONVERSATION_READ_TIMEOUT_MS } from "@/lib/community/conversation-read" import { communityKeys } from "@/lib/query-keys" const apiFetchMock = vi.fn() @@ -48,6 +49,44 @@ const emptyIncluded = { } describe("forumFeedPageQueryFn", () => { + it.each(["deadline", "cancellation"])("retires a hanging feed on %s without late profiles or canonical writes", async (reason) => { + let release!: (value: unknown) => void + apiFetchMock.mockImplementationOnce(() => new Promise((resolve) => { release = resolve })) + const controller = new AbortController() + vi.useFakeTimers() + try { + const result = forumFeedPageQueryFn("forum_1", null, client)({ pageParam: null, signal: controller.signal }) + const rejected = expect(result).rejects.toMatchObject({ name: reason === "deadline" ? "ConversationReadTimeoutError" : "AbortError" }) + await vi.waitFor(() => expect(apiFetchMock).toHaveBeenCalledOnce()) + if (reason === "deadline") await vi.advanceTimersByTimeAsync(CONVERSATION_READ_TIMEOUT_MS) + else controller.abort(new DOMException("Retired", "AbortError")) + await rejected + expect(apiFetchMock.mock.calls[0]![1].signal.aborted).toBe(true) + release({ serverId: "server_1", parentType: "forum", threads: [], included: { ...emptyIncluded, parentMessages: [{ id: "late-opener", channelId: "forum_1", seq: 1, content: "late", authorId: "late-author", authorName: "Late", authorImage: null, authorAvatarVersion: 1 }] }, hasMore: false }) + await Promise.resolve() + await Promise.resolve() + expect(getCanonicalCommunityMessages(client)).toEqual([]) + expect(getCommunityDbRegistry(client)!.collections.profiles.get("late-author")).toBeUndefined() + apiFetchMock.mockResolvedValueOnce({ serverId: "server_1", parentType: "forum", threads: [], included: emptyIncluded, hasMore: false }) + await expect(forumFeedPageQueryFn("forum_1", null, client)({ pageParam: null })).resolves.toMatchObject({ threads: [], hasMore: false }) + } finally { vi.useRealTimers() } + }) + + it("includes feed collection preloads in the deadline", async () => { + let release!: () => void + vi.spyOn(getCommunityDbRegistry(client)!.collections.messages, "preload").mockImplementationOnce(() => new Promise((resolve) => { release = resolve })) + vi.useFakeTimers() + try { + const result = forumFeedPageQueryFn("forum_1", null, client)({ pageParam: null }) + const rejected = expect(result).rejects.toMatchObject({ name: "ConversationReadTimeoutError" }) + await vi.advanceTimersByTimeAsync(CONVERSATION_READ_TIMEOUT_MS) + await rejected + release() + await Promise.resolve() + expect(apiFetchMock).not.toHaveBeenCalled() + } finally { vi.useRealTimers() } + }) + beforeEach(() => { apiFetchMock.mockReset() @@ -86,7 +125,7 @@ describe("forumFeedPageQueryFn", () => { expect(apiFetchMock).toHaveBeenCalledWith( expect.stringContaining("/api/community/channels/forum_one/threads?"), - expect.objectContaining({ signal: controller.signal, authenticationAccount: "viewer" }), + expect.objectContaining({ signal: expect.any(AbortSignal), authenticationAccount: "viewer" }), ) }) diff --git a/src/web/src/hooks/community/use-forum-feed.ts b/src/web/src/hooks/community/use-forum-feed.ts index 2f44188e0..08b823426 100644 --- a/src/web/src/hooks/community/use-forum-feed.ts +++ b/src/web/src/hooks/community/use-forum-feed.ts @@ -7,6 +7,7 @@ import { compareAsciiSqliteBinary, DEFAULT_MESSAGE_PAGE_SIZE } from "@alook/shar import { apiFetch } from "@/lib/api/client" import { writeCommunityProfilePatches } from "@/lib/community/profile-seed" import { communityRequestOptions } from "@/lib/community/account-cache-lifecycle" +import { conversationReadRetryPolicy, withConversationReadDeadline } from "@/lib/community/conversation-read" import { communityKeys } from "@/lib/query-keys" import { canonicalUserImage } from "@/lib/community/storage" import { avatarInitial } from "@/lib/community/avatar" @@ -41,20 +42,20 @@ function beginForumRead(queryClient: ReturnType, channelI } export function forumFeedPageQueryFn(channelId: string, tag: string | null, queryClient: ReturnType) { - return async ({ pageParam, signal }: { pageParam: string | null; signal?: AbortSignal }) => { + return ({ pageParam, signal }: { pageParam: string | null; signal?: AbortSignal }) => withConversationReadDeadline(signal, async (readSignal) => { const protocol = beginForumRead(queryClient, channelId, tag) const token = protocol.get().token, registry = getCommunityDbRegistry(queryClient) await registry?.ready const resource = queryClient.getQueryCache().find({ queryKey: communityKeys.forumFeed(channelId, tag), exact: true }) if (resource && forumReads.get(resource) === protocol && protocol.get().operation === undefined) protocol.setState((state) => ({ ...state, operation: resource.promise })) - assertCommunityLiveSnapshotTokenCurrent(queryClient, token, signal) + assertCommunityLiveSnapshotTokenCurrent(queryClient, token, readSignal) await Promise.all([registry!.collections.channels.preload(), registry!.collections.messages.preload(), registry!.collections.channelMemberships.preload(), registry!.collections.profiles.preload()]) - assertCommunityLiveSnapshotTokenCurrent(queryClient, token, signal) + assertCommunityLiveSnapshotTokenCurrent(queryClient, token, readSignal) const params = new URLSearchParams({ order: "createdAt", limit: String(DEFAULT_MESSAGE_PAGE_SIZE), include: "parentMessage,firstMessage,tags,participants" }) if (tag) params.set("tag", tag) if (pageParam) params.set("cursor", pageParam) - const page = await apiFetch(`/api/community/channels/${channelId}/threads?${params}`, communityRequestOptions(queryClient, token, signal)) - assertCommunityLiveSnapshotTokenCurrent(queryClient, token, signal) + const page = await apiFetch(`/api/community/channels/${channelId}/threads?${params}`, communityRequestOptions(queryClient, token, readSignal)) + assertCommunityLiveSnapshotTokenCurrent(queryClient, token, readSignal) const freshThreads = page.threads.filter((thread) => !protocol.get().publishedIds.has(thread.id)) const ids = new Set(freshThreads.map((thread) => thread.id)), openerIds = new Set(freshThreads.map((thread) => thread.parentMessageId)) const duplicateOpeners = new Set(page.threads.filter((thread) => protocol.get().publishedIds.has(thread.id)).map((thread) => thread.parentMessageId)) @@ -67,10 +68,10 @@ export function forumFeedPageQueryFn(channelId: string, tag: string | null, quer firstMessages: page.included.firstMessages.filter((message) => ids.has(message.channelId)), tags: page.included.tags.filter((row) => openerIds.has(row.messageId)), participants: page.included.participants.filter((row) => ids.has(row.channelId)), - } }, { token, signal }) + } }, { token, signal: readSignal }) protocol.setState((state) => ({ ...state, publishedIds: new Set([...state.publishedIds, ...ids]) })) return forumFeedWindow(page) - } + }) } export function mapForumFeedPages(pages: ForumFeedPage[], messages: ReadonlyMap, channels: ReadonlyMap, profiles: ReturnType, filter: string | null = null): ForumThread[] { @@ -99,7 +100,9 @@ export function useForumFeed(_serverId: string, channelId: string) { const tagsQuery = useForumTags(channelId, true) useEffect(() => { if (tagsQuery.isSuccess && tag !== "All" && validateForumTagSelection(tag, tagsQuery.data.tags) === "All") selectTag("All") }, [tag, tagsQuery.isSuccess, tagsQuery.data, selectTag]) const selectedTag = tag === "All" ? null : tag - const query = useInfiniteQuery({ queryKey: communityKeys.forumFeed(channelId, selectedTag), queryFn: forumFeedPageQueryFn(channelId, selectedTag, queryClient), initialPageParam: null as string | null, getNextPageParam: (lastPage) => lastPage.hasMore ? lastPage.nextCursor : undefined }) + const queryKey = communityKeys.forumFeed(channelId, selectedTag) + const query = useInfiniteQuery({ queryKey, queryFn: forumFeedPageQueryFn(channelId, selectedTag, queryClient), initialPageParam: null as string | null, getNextPageParam: (lastPage) => lastPage.hasMore ? lastPage.nextCursor : undefined, + retry: conversationReadRetryPolicy(queryClient.defaultQueryOptions({ queryKey }).retry), networkMode: "always" }) const posts = useMemo(() => mapForumFeedPages(query.data?.pages ?? [], messages ?? new Map(), channels, profiles, selectedTag), [query.data?.pages, messages, channels, profiles, selectedTag]) return { ...query, posts, tag, selectTag, availableTags: tagsQuery.data?.tags ?? [], hasMoreOlder: query.hasNextPage, isFetchingOlder: query.isFetchingNextPage, fetchOlder: () => { void query.fetchNextPage() } } } diff --git a/src/web/src/hooks/community/use-forum-opener-hint.dom.test.ts b/src/web/src/hooks/community/use-forum-opener-hint.dom.test.ts index 19007d2a8..180531b9b 100644 --- a/src/web/src/hooks/community/use-forum-opener-hint.dom.test.ts +++ b/src/web/src/hooks/community/use-forum-opener-hint.dom.test.ts @@ -3,6 +3,7 @@ import { createElement, type PropsWithChildren } from "react" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { act, renderHook, waitFor } from "@/test/react-dom-harness" +import { CONVERSATION_READ_TIMEOUT_MS } from "@/lib/community/conversation-read" import { communityKeys } from "@/lib/query-keys" import { createCommunityDbRegistry, @@ -11,6 +12,7 @@ import { import { CommunityDbProvider } from "@/lib/community-db/projections" import { captureCommunityLiveSnapshotToken, + getCanonicalCommunityMessages, publishCommunityMessages, } from "@/lib/community-db/sync" import { useCommunityWsStore } from "@/stores/community/ws" @@ -54,6 +56,41 @@ async function canonicalSetup() { } describe("useForumOpenerHint", () => { + it("times out a cold opener, rejects its late publication, and recovers through refetch", async () => { + const { queryClient, wrapper } = await canonicalSetup() + let release!: (value: unknown) => void + const payload = { id: "opener-1", content: "Recovered title", seq: 7, channelId: "forum-1", type: "chat" } + apiFetchMock.mockImplementationOnce(() => new Promise((resolve) => { release = resolve })) + vi.useFakeTimers() + let rendered!: ReturnType, unknown>> + try { + rendered = renderHook(() => useForumOpenerHint("server-1", "opener-1", true), { wrapper }) + await vi.waitFor(() => expect(apiFetchMock).toHaveBeenCalledOnce()) + await act(async () => { await vi.advanceTimersByTimeAsync(CONVERSATION_READ_TIMEOUT_MS) }) + expect(queryClient.getQueryState(communityKeys.message("opener-1"))?.error).toMatchObject({ name: "ConversationReadTimeoutError" }) + expect(apiFetchMock.mock.calls[0]![1].signal.aborted).toBe(true) + } finally { vi.useRealTimers() } + await waitFor(() => expect(rendered.result.current.isError).toBe(true)) + expect(rendered.result.current.isLoading).toBe(false) + await act(async () => { release(payload) }) + expect(getCanonicalCommunityMessages(queryClient)).toEqual([]) + apiFetchMock.mockResolvedValueOnce(payload) + await act(async () => { await rendered.result.current.refetch() }) + await waitFor(() => expect(rendered.result.current.data?.content).toBe("Recovered title")) + rendered.unmount() + }) + + it("keeps a readable canonical opener after a transient background failure", async () => { + const { queryClient, wrapper } = await canonicalSetup() + publishCommunityMessages(queryClient, { channelId: "forum-1", messages: [{ id: "opener-1", channelId: "forum-1", type: "chat", content: "Warm title", seq: 7 }], proof: { token: captureCommunityLiveSnapshotToken(queryClient), signal: undefined } }) + apiFetchMock.mockRejectedValue(new Error("offline")) + const rendered = renderHook(() => useForumOpenerHint("server-1", "opener-1", true), { wrapper }) + await waitFor(() => expect(rendered.result.current.isError).toBe(true)) + expect(rendered.result.current.data?.content).toBe("Warm title") + expect(rendered.result.current.isLoading).toBe(false) + rendered.unmount() + }) + it("does not fetch or authorize content before the route metadata is verified", () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) renderHook(() => useForumOpenerHint("server-1", "opener-1", false), { diff --git a/src/web/src/hooks/community/use-forum-opener-hint.ts b/src/web/src/hooks/community/use-forum-opener-hint.ts index bd217677f..6d0c9133b 100644 --- a/src/web/src/hooks/community/use-forum-opener-hint.ts +++ b/src/web/src/hooks/community/use-forum-opener-hint.ts @@ -13,6 +13,7 @@ import { assertCommunityLiveSnapshotTokenCurrent, } from "@/lib/community-db/sync" import { communityRequestOptions } from "@/lib/community/account-cache-lifecycle" +import { conversationReadRetryPolicy, withConversationReadDeadline } from "@/lib/community/conversation-read" export function useForumOpenerHint( serverId: string, @@ -23,12 +24,13 @@ export function useForumOpenerHint( const registry = useOptionalCommunityDbRegistry() const canonicalMessages = useCanonicalMessagesById() const queryClient = useQueryClient() + const queryKey = communityKeys.message(messageId ?? "__none__") const query = useQuery<{ id: string }>({ - queryKey: communityKeys.message(messageId ?? "__none__"), - queryFn: async ({ signal }) => { + queryKey, + queryFn: ({ signal }) => withConversationReadDeadline(signal, async (readSignal) => { const token = captureCommunityLiveSnapshotToken(queryClient) await token.registry!.ready - assertCommunityLiveSnapshotTokenCurrent(queryClient, token, signal) + assertCommunityLiveSnapshotTokenCurrent(queryClient, token, readSignal) const message = await apiFetch<{ id: string content: string @@ -37,20 +39,22 @@ export function useForumOpenerHint( type: "chat" | "system" }>( `/api/community/messages/${messageId}`, - communityRequestOptions(queryClient, token, signal), + communityRequestOptions(queryClient, token, readSignal), ) publishCommunityMessages(queryClient, { channelId: message.channelId, messages: [message], - proof: { token, signal }, + proof: { token, signal: readSignal }, }) return { id: message.id, } - }, + }), enabled: active, staleTime: Infinity, gcTime: 5 * 60 * 1000, + retry: conversationReadRetryPolicy(queryClient.defaultQueryOptions({ queryKey }).retry), + networkMode: "always", }) const canonical = messageId ? canonicalMessages?.get(messageId) : undefined const data = canonical && typeof canonical.content === "string" diff --git a/src/web/src/hooks/community/use-messages.activation-refetch.dom.test.ts b/src/web/src/hooks/community/use-messages.activation-refetch.dom.test.ts index d620ad2f6..e706c3585 100644 --- a/src/web/src/hooks/community/use-messages.activation-refetch.dom.test.ts +++ b/src/web/src/hooks/community/use-messages.activation-refetch.dom.test.ts @@ -147,14 +147,16 @@ function PaginatingChannelCapture({ lastReadMessageId, onRender }: { return null } -function RefetchingPaginationCapture({ onRender }: { +function RefetchingPaginationCapture({ channelId = "ch_activation", onRender }: { + channelId?: string | null onRender: ( snapshot: Snapshot, refetch: () => Promise, fetchOlder: () => void, + fetchNewer: () => void, ) => void }) { - const result = useMessages("ch_activation", { + const result = useMessages(channelId, { serverId: "server_1", lastReadMessageId: "m_anchor", revalidateOnMount: false, @@ -164,7 +166,7 @@ function RefetchingPaginationCapture({ onRender }: { hasMoreNewer: result.hasMoreNewer, ids: result.messages.map((message) => message.id), isFetching: result.isFetching, - }, result.refetch, result.fetchOlder) + }, result.refetch, result.fetchOlder, result.fetchNewer) return null } @@ -1612,6 +1614,79 @@ describe("useMessagesInner — disabled-to-enabled cache revalidation", () => { renderer.unmount() }) + it.each(["older", "newer"] as const)("serializes the opposite page behind an in-flight %s read without aborting it", async firstDirection => { + const { client: queryClient } = await createCommunityQueryOwner("viewer", { defaultOptions: { queries: { retry: false } } }) + seedCommunityMessageWindow(queryClient, communityKeys.channelMessages("ch_activation"), { + pages: [{ + messages: [{ id: "m_anchor", seq: 2, createdAt: "2026-08-09T00:00:01.000Z" }], + hasMoreOlder: true, hasMoreNewer: true, olderCursor: "older-cursor", newerCursor: "newer-cursor", latestSeq: 3, + }], + pageParams: [{ mode: "anchor", anchor: "m_anchor" }], + }) + const held = deferred() + const olderPage: MessagesPage = { messages: [{ id: "m_old", seq: 1, createdAt: "2026-08-09T00:00:00.000Z" }], hasMoreOlder: false, hasMoreNewer: true, newerCursor: "newer-cursor", latestSeq: 3 } + const newerPage: MessagesPage = { messages: [{ id: "m_new", seq: 3, createdAt: "2026-08-09T00:00:02.000Z" }], hasMoreOlder: true, hasMoreNewer: false, olderCursor: "older-cursor", latestSeq: 3 } + const firstUrl = firstDirection === "older" ? "?cursor=older-cursor" : "?since=newer-cursor" + apiFetchMock.mockImplementation((url: string) => url.endsWith(firstUrl) + ? held.promise : Promise.resolve(url.includes("?cursor=") ? olderPage : newerPage)) + const snapshots: Snapshot[] = [] + let older = () => {} + let newer = () => {} + const renderer = renderCapture(queryClient, React.createElement(RefetchingPaginationCapture, { + onRender: (snapshot, _refetch, nextOlder, nextNewer) => { snapshots.push(snapshot); older = nextOlder; newer = nextNewer }, + })) + try { + act(() => firstDirection === "older" ? older() : newer()) + await waitFor(() => snapshots.at(-1)?.isFetching === true) + expect(apiFetchMock).toHaveBeenCalledOnce() + const firstSignal = apiFetchMock.mock.calls[0]?.[1]?.signal as AbortSignal + act(() => firstDirection === "older" ? newer() : older()) + expect(firstSignal.aborted).toBe(false) + expect(apiFetchMock).toHaveBeenCalledOnce() + held.resolve(firstDirection === "older" ? olderPage : newerPage) + await waitFor(() => snapshots.at(-1)?.ids.length === 3) + expect(snapshots.at(-1)?.ids).toEqual(["m_old", "m_anchor", "m_new"]) + expect(apiFetchMock).toHaveBeenCalledTimes(2) + } finally { + held.resolve(firstDirection === "older" ? olderPage : newerPage) + renderer.unmount() + } + }) + + it.each(["unmount", "disable", "remove"] as const)("retires a queued opposite page after %s", async retirement => { + const { client: queryClient } = await createCommunityQueryOwner("viewer", { defaultOptions: { queries: { retry: false } } }) + const key = communityKeys.channelMessages("ch_activation") + seedCommunityMessageWindow(queryClient, key, { + pages: [{ messages: [{ id: "m_anchor", seq: 2 }], hasMoreOlder: true, hasMoreNewer: true, olderCursor: "older-cursor", newerCursor: "newer-cursor", latestSeq: 3 }], + pageParams: [{ mode: "anchor", anchor: "m_anchor" }], + }) + const held = deferred() + apiFetchMock.mockImplementation(() => held.promise) + let older = () => {} + let newer = () => {} + const onRender = (_snapshot: Snapshot, _refetch: () => Promise, nextOlder: () => void, nextNewer: () => void) => { older = nextOlder; newer = nextNewer } + const renderer = renderCapture(queryClient, React.createElement(RefetchingPaginationCapture, { onRender })) + let unmounted = false + try { + act(() => older()) + await waitFor(() => apiFetchMock.mock.calls.length === 1) + act(() => newer()) + if (retirement === "unmount") { renderer.unmount(); unmounted = true } + else if (retirement === "disable") updateCapture(renderer, queryClient, React.createElement(RefetchingPaginationCapture, { channelId: null, onRender })) + else act(() => queryClient.removeQueries({ queryKey: key, exact: true })) + await act(async () => { + held.resolve({ messages: [{ id: "m_old", seq: 1 }], hasMoreOlder: false, hasMoreNewer: true, newerCursor: "newer-cursor", latestSeq: 3 }) + await held.promise + await new Promise(resolve => setTimeout(resolve, 10)) + }) + expect(apiFetchMock).toHaveBeenCalledOnce() + if (retirement === "remove") expect(queryClient.getQueryCache().find({ queryKey: key, exact: true })).toBeUndefined() + } finally { + held.resolve({ messages: [], hasMoreOlder: false, hasMoreNewer: false, latestSeq: 3 }) + if (!unmounted) renderer.unmount() + } + }) + it("ignores an old view's pagination callback after the conversation changes", async () => { const { client: queryClient } = await createCommunityQueryOwner("viewer", { defaultOptions: { queries: { retry: false } } }) seedCommunityMessageWindow(queryClient, communityKeys.channelMessages("ch_a"), { diff --git a/src/web/src/hooks/community/use-messages.dom.test.ts b/src/web/src/hooks/community/use-messages.dom.test.ts index 8dce76e79..2ec92e718 100644 --- a/src/web/src/hooks/community/use-messages.dom.test.ts +++ b/src/web/src/hooks/community/use-messages.dom.test.ts @@ -1,9 +1,11 @@ import { CommunityTestProvider as QueryClientProvider } from "@/test/community-owner-fixture" import { createCommunityQueryOwner } from "@/test/community-query-owner" -import { describe, it, expect, vi, beforeEach } from "vitest" +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" import { createElement, type PropsWithChildren } from "react" import { type InfiniteData } from "@tanstack/react-query" -import { renderHook, waitFor } from "@/test/react-dom-harness" +import { act, renderHook, waitFor } from "@/test/react-dom-harness" +import { getCommunityDbRegistry } from "@/lib/community-db/collections" +import { CONVERSATION_READ_TIMEOUT_MS, ConversationReadTimeoutError } from "@/lib/community/conversation-read" import { communityKeys } from "@/lib/query-keys" import type { Msg } from "@/lib/community/models/message" @@ -17,6 +19,8 @@ beforeEach(() => { }) +afterEach(() => vi.useRealTimers()) + // Load *after* the mock is set up so the queryFn resolves the mocked import. async function loadHook() { return await import("./use-messages") @@ -391,3 +395,28 @@ describe("mergeMessagesPages", () => { expect(mergeMessagesPages([])).toEqual([]) }) }) + + +describe("bounded messages publication", () => { + it("does not publish a late message, profile or navigation receipt after deadline failure", async () => { + const { channelMessagesQueryFn } = await loadHook() + const { client } = await createCommunityQueryOwner() + let resolve!: (value: unknown) => void + apiFetchMock.mockImplementationOnce(() => new Promise((done) => { resolve = done })) + const receipt = vi.fn() + vi.useFakeTimers() + const request = channelMessagesQueryFn("ch_deadline", null, { queryClient: client, onSurfaceReceipt: receipt })({ client, signal: new AbortController().signal, pageParam: { mode: "newest" } }) + const rejected = expect(request).rejects.toBeInstanceOf(ConversationReadTimeoutError) + await act(async () => { await vi.advanceTimersByTimeAsync(CONVERSATION_READ_TIMEOUT_MS) }) + await rejected + expect(apiFetchMock.mock.calls[0][1].signal.aborted).toBe(true) + await act(async () => { + resolve({ messages: [{ id: "late_message", type: "chat", seq: 1, authorId: "late_author", authorName: "Late", content: "late" }], hasMore: false, latestSeq: 1, surfaceReceipt: { channelId: "ch_deadline", surfaceKind: "channel" } }) + await vi.advanceTimersByTimeAsync(0) + }) + const registry = getCommunityDbRegistry(client)! + expect(registry.collections.messages.get("late_message")).toBeUndefined() + expect(registry.collections.profiles.get("late_author")).toBeUndefined() + expect(receipt).not.toHaveBeenCalled() + }) +}) diff --git a/src/web/src/hooks/community/use-messages.ts b/src/web/src/hooks/community/use-messages.ts index 301276279..4413fdbcd 100644 --- a/src/web/src/hooks/community/use-messages.ts +++ b/src/web/src/hooks/community/use-messages.ts @@ -46,6 +46,7 @@ import { captureCommunityLiveSnapshotToken, publishCommunityMessages, } from "@/lib/community-db/sync" +import { ConversationReadTimeoutError, conversationReadRetryPolicy, withConversationReadDeadline } from "@/lib/community/conversation-read" import { messageReconcileOptions } from "./community-ws/reconnect-messages" /** @@ -153,11 +154,11 @@ async function fetchMessagesTransport( signal: AbortSignal | undefined, options: MessagesTransportOptions | undefined, ): Promise { - const transport = await apiFetchProfiles( + const transport = await withConversationReadDeadline(signal, (readSignal) => apiFetchProfiles( url, (page) => messageProfilePatches(page.messages), - signal ? { signal } : undefined, getCommunityDbRegistry(queryClient), - ) + { signal: readSignal }, getCommunityDbRegistry(queryClient), + )) const { surfaceReceipt, ...page } = transport if (isMessageSurfaceReceipt(surfaceReceipt)) { options?.onSurfaceReceipt?.(surfaceReceipt) @@ -515,6 +516,8 @@ function useMessagesInner( } }, initialPageParam, + retry: conversationReadRetryPolicy(queryClient.defaultQueryOptions({ queryKey }).retry), + networkMode: "always", // "next" = older side. `fetchNextPage` appends to `data.pages`, so the // LAST entry in `pages` is the oldest window we've loaded — that's the // page whose cursor gets consulted for the next older fetch. @@ -710,6 +713,13 @@ function useMessagesInner( }, [jumpPending, presentOverride, query.isError, queryClient, queryKey, setPresentOverride, viewKey]) const messageQuery = queryClient.getQueryCache().find({ queryKey, exact: true }) + const paginationOwnerRef = useRef<{ activationKey: string; query: typeof messageQuery } | null>(null) + useLayoutEffect(() => { + if (!enabled || !messageQuery) { paginationOwnerRef.current = null; return } + const owner = { activationKey, query: messageQuery } + paginationOwnerRef.current = owner + return () => { if (paginationOwnerRef.current === owner) paginationOwnerRef.current = null } + }, [activationKey, enabled, messageQuery]) useLayoutEffect(() => { if (!enabled || !messageQuery) return // A native observer spans the route's commit gap. The next mount joins @@ -759,7 +769,7 @@ function useMessagesInner( const repairOptions = { queryKey: [...queryKey, "anchor-repair", anchorId, accessIdentity], queryFn: ({ signal }: { signal: AbortSignal }) => { if (!isChannelMetadataTokenCurrent(accessToken) || queryClient.getQueryCache().find({ queryKey, exact: true }) !== currentQuery) throw new DOMException("Retired message window", "AbortError") return queryFn({ pageParam: anchorPageParam, signal }) - }, staleTime: 0, retry: (attempt: number, error: unknown) => !(error instanceof DOMException && error.name === "AbortError") && attempt < 2, retryDelay: (attempt: number) => 1000 * 2 ** attempt } + }, staleTime: 0, networkMode: "always" as const, retry: (attempt: number, error: unknown) => !(error instanceof ConversationReadTimeoutError) && !(error instanceof DOMException && error.name === "AbortError") && attempt < 2, retryDelay: (attempt: number) => 1000 * 2 ** attempt } const lease = new QueryObserver(queryClient, { ...repairOptions, enabled: false }) const unsubscribe = lease.subscribe(() => undefined) const releaseOnRemoval = queryClient.getQueryCache().subscribe((event) => { @@ -856,43 +866,31 @@ function useMessagesInner( const hasMoreOlder = (oldestPage?.hasMoreOlder ?? oldestPage?.hasMore) ?? false const hasMoreNewer = newestPage?.hasMoreNewer ?? false - // Callbacks depend on `query.*` fields that TanStack refreshes on every - // internal state change — closing over the whole query object keeps the - // exhaustive-deps rule happy without spelling every subfield. - const fetchOlder = useCallback(() => { - if (!enabled) return - if (!query.hasNextPage) return - if (query.isFetchingNextPage) return - const activationState = activationRevalidationRef.current - if (activationState.activationKey !== activationKey) return - const activationRequest = activationState.pending - if (activationRequest) { - void activationRequest.then(() => { - if (activationRevalidationRef.current.activationKey !== activationKey) return - void query.fetchNextPage({ cancelRefetch: false }) - }) - return - } - // Initial-position sentinels can intersect while a retained-mount - // revalidation is still in flight. Queue their pagination behind that - // semantic request instead of letting fetchNextPage cancel it and make a - // cursor GET the first completed request for the mount. - if (query.isFetching) { - void query.refetch({ cancelRefetch: false }).then(() => { - // The observer method reads the just-refreshed page/cursor state. If - // that page has no older cursor, TanStack resolves without a request. - void query.fetchNextPage({ cancelRefetch: false }) - }) - return + const fetchPage = useCallback((direction: "older" | "newer") => { + const owner = paginationOwnerRef.current + if (!enabled || !scopeId || !owner?.query || owner.activationKey !== activationKey) return + if (direction === "older" ? !query.hasNextPage || query.isFetchingNextPage + : !query.hasPreviousPage || query.isFetchingPreviousPage) return + const currentQuery = owner.query + const accessToken = captureChannelMetadataToken(queryClient, scopeId) + const isCurrent = () => paginationOwnerRef.current === owner + && activationRevalidationRef.current.activationKey === activationKey + && queryClient.getQueryCache().find({ queryKey, exact: true }) === currentQuery + && isChannelMetadataTokenCurrent(accessToken) + const fetch = async () => { + const activationRequest = activationRevalidationRef.current.pending + if (activationRequest) await activationRequest + while (isCurrent() && currentQuery.state.fetchStatus !== "idle") { + await query.refetch({ cancelRefetch: false }) + } + if (!isCurrent()) return + if (direction === "older") await query.fetchNextPage({ cancelRefetch: false }) + else await query.fetchPreviousPage({ cancelRefetch: false }) } - void query.fetchNextPage() - }, [activationKey, enabled, query]) - - const fetchNewer = useCallback(() => { - if (!query.hasPreviousPage) return - if (query.isFetchingPreviousPage) return - void query.fetchPreviousPage() - }, [query]) + void fetch() + }, [activationKey, enabled, query, queryClient, queryKey, scopeId]) + const fetchOlder = useCallback(() => fetchPage("older"), [fetchPage]) + const fetchNewer = useCallback(() => fetchPage("newer"), [fetchPage]) const jumpToPresent = useCallback(() => { if (!enabled || forceNewest) return diff --git a/src/web/src/hooks/community/use-read-observer.dom.test.ts b/src/web/src/hooks/community/use-read-observer.dom.test.ts index 9968b09dc..46858a710 100644 --- a/src/web/src/hooks/community/use-read-observer.dom.test.ts +++ b/src/web/src/hooks/community/use-read-observer.dom.test.ts @@ -146,6 +146,7 @@ function takeUnmounts() { return unmounts.splice(0) } function trigger(record: ObserverRecord, target: Element, ratio = 1) { const entry = { target, + time: performance.now(), isIntersecting: true, intersectionRatio: ratio, } as IntersectionObserverEntry @@ -155,6 +156,7 @@ function trigger(record: ObserverRecord, target: Element, ratio = 1) { function queueEntry(record: ObserverRecord, target: Element, ratio = 1) { record.queued.push({ target, + time: performance.now(), isIntersecting: true, intersectionRatio: ratio, } as IntersectionObserverEntry) @@ -166,7 +168,7 @@ function deliverQueued(record: ObserverRecord) { function presentationAttributeRecord( boundary: ContentBoundary, - attributeName: "aria-hidden" | "inert", + attributeName: "aria-hidden" | "inert" | "data-read-position-ready", ) { return { type: "attributes", @@ -178,7 +180,7 @@ function presentationAttributeRecord( function setPresentationAttributeReadable( boundary: ContentBoundary, - attributeName: "aria-hidden" | "inert", + attributeName: "aria-hidden" | "inert" | "data-read-position-ready", ) { if (attributeName === "aria-hidden") boundary.setAriaHidden(false) else boundary.setInert(false) @@ -387,6 +389,31 @@ describe("useTimelineReadObserver", () => { expect(coordinator.resume).not.toHaveBeenCalled() }) + it("keeps revealed semantic-pending content silent, drains old records, and opens on fresh geometry", () => { + hookState.candidate = { channelId: "channel-1", lastMessageAt: "t4", fingerprint: "semantic-t4", openerUnread: false } + const row = makeRow("message-4") + const boundary = makeContentBoundary(true) + boundary.setAttribute("data-read-position-ready", "false") + useTestRender({ scrollRootEl: makeRoot([row], boundary) }) + takeUnmounts() + const record = observers[0]! + trigger(record, row) + queueEntry(record, row) + const stale = record.queued[0]! + expect(coordinator.submit).not.toHaveBeenCalled() + expect(projection.recordOptimisticRead).not.toHaveBeenCalled() + expect(reservation.promote).not.toHaveBeenCalled() + boundary.setAttribute("data-read-position-ready", "true") + act(() => mutationCallback!([presentationAttributeRecord(boundary, "data-read-position-ready")], {} as MutationObserver)) + expect(record.queued).toHaveLength(0) + act(() => record.callback([stale], {} as IntersectionObserver)) + expect(coordinator.submit).not.toHaveBeenCalled() + trigger(record, row) + expect(coordinator.submit).toHaveBeenCalledOnce() + expect(projection.recordOptimisticRead).toHaveBeenCalledOnce() + expect(reservation.promote).toHaveBeenCalledOnce() + }) + it.each([ { snapshotStatus: "error" as const }, { feedStatus: "error" as const }, @@ -553,7 +580,8 @@ describe("useTimelineReadObserver", () => { expect(mutationObservedTarget).toBe(root) expect(mutationObserveOptions).toEqual({ attributes: true, - attributeFilter: ["aria-hidden", "inert"], + attributeFilter: ["aria-hidden", "inert", "data-read-position-ready"], + attributeOldValue: true, childList: true, subtree: true, }) diff --git a/src/web/src/hooks/community/use-read-observer.ts b/src/web/src/hooks/community/use-read-observer.ts index 4ec3be99d..c44abdbbb 100644 --- a/src/web/src/hooks/community/use-read-observer.ts +++ b/src/web/src/hooks/community/use-read-observer.ts @@ -31,6 +31,7 @@ function readPresentationReadable(scrollRootEl: HTMLElement) { if (!content) return true return content.getAttribute("aria-hidden") === "false" && !content.hasAttribute("inert") + && content.getAttribute("data-read-position-ready") !== "false" } export type ReadCandidate = { @@ -172,11 +173,11 @@ export function useTimelineReadObserver({ if (!readLease || !reservationLease) return let observerGeneration = 0 let presentationReadable = readPresentationReadable(scrollRootEl) - const bindings = new WeakMap() + const bindings = new WeakMap() const bind = (node: Element) => { const id = (node as HTMLElement).dataset.msgId if (!id) return - bindings.set(node, { id, generation: observerGeneration }) + bindings.set(node, { id, generation: observerGeneration, observedAt: performance.now() }) observer.observe(node) } const observer = new IntersectionObserver((entries) => { @@ -188,7 +189,7 @@ export function useTimelineReadObserver({ } for (const entry of entries) { const binding = bindings.get(entry.target) - if (!binding || binding.generation !== observerGeneration) continue + if (!binding || binding.generation !== observerGeneration || entry.time < binding.observedAt) continue if (!scrollRootEl.contains(entry.target)) continue if ((entry.target as HTMLElement).dataset.msgId !== binding.id) continue const message = protocol.get().visibleIds.has(binding.id) ? getCommunityDbRegistry(queryClient)?.collections.messages.get(binding.id) : undefined @@ -235,12 +236,16 @@ export function useTimelineReadObserver({ : new MutationObserver((records) => { const nextPresentationReadable = readPresentationReadable(scrollRootEl) const presentationRevealed = !presentationReadable && nextPresentationReadable - presentationReadable = nextPresentationReadable - if (presentationRevealed) { - scrollRootEl.querySelectorAll("[data-msg-id]").forEach((node) => { - observer.unobserve(node) - }) + const presentationChanged = presentationReadable !== nextPresentationReadable + || records.some((record) => record.type === "attributes" + && record.attributeName === "data-read-position-ready" && record.oldValue === "false") + if (presentationChanged) { + observerGeneration += 1 + scrollRootEl.querySelectorAll("[data-msg-id]").forEach((node) => observer.unobserve(node)) observer.takeRecords() + } + presentationReadable = nextPresentationReadable + if (presentationRevealed || (presentationChanged && nextPresentationReadable)) { sample() if (document.visibilityState === "visible") classifyCandidateRef.current() return @@ -256,7 +261,8 @@ export function useTimelineReadObserver({ }) mutations?.observe(scrollRootEl, { attributes: true, - attributeFilter: ["aria-hidden", "inert"], + attributeFilter: ["aria-hidden", "inert", "data-read-position-ready"], + attributeOldValue: true, childList: true, subtree: true, }) diff --git a/src/web/src/hooks/community/use-scroll-anchor.compensate-delta.dom.test.ts b/src/web/src/hooks/community/use-scroll-anchor.compensate-delta.dom.test.ts index f06ebddaf..b5ed81ecf 100644 --- a/src/web/src/hooks/community/use-scroll-anchor.compensate-delta.dom.test.ts +++ b/src/web/src/hooks/community/use-scroll-anchor.compensate-delta.dom.test.ts @@ -1,89 +1,70 @@ -import { createElement } from "react" -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" -import { render, screen } from "@/test/react-dom-harness" -import { useScrollAnchor } from "./use-scroll-anchor" -import type { FlatItem } from "@/lib/community/message-list-items" +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest" +import { act, fireEvent } from "@/test/react-dom-harness" +import { bodyTop, installMessageScrollFixture, restoreMessageScrollFixture, message, mount, resize, runFrames, scrollFixture } from "@/test/message-scroll-fixture" -const items: FlatItem[] = [ - { kind: "message", m: { id: "m1", type: "chat", grouped: false }, key: "msg:m1" }, - { kind: "message", m: { id: "m2", type: "chat", grouped: false }, key: "msg:m2" }, -] - -function Harness({ heroHeight }: { heroHeight: number }) { - const { scrollRef } = useScrollAnchor({ - items, - initialScrollReady: true, - heroHeight, - }) - return createElement("div", { ref: scrollRef, "data-testid": "scroll" }) -} - -describe("useScrollAnchor hero-swap compensation", () => { - let scrollToDescriptor: PropertyDescriptor | undefined +beforeEach(() => { + installMessageScrollFixture() + vi.spyOn(navigator, "userAgent", "get").mockReturnValue("iPhone AppleWebKit") +}) +afterEach(restoreMessageScrollFixture) - beforeEach(() => { - vi.stubGlobal("ResizeObserver", class { observe() {} unobserve() {} disconnect() {} }) - vi.stubGlobal("IntersectionObserver", class { observe() {} unobserve() {} disconnect() {} }) - vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(1_000) - vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(500) - vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({ - top: 0, - left: 0, - right: 0, - bottom: 0, - width: 0, - height: 0, - x: 0, - y: 0, - toJSON: () => ({}), - }) - scrollToDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollTo") - Object.defineProperty(HTMLElement.prototype, "scrollTo", { - configurable: true, - writable: true, - value(this: HTMLElement, { top }: ScrollToOptions) { - this.scrollTop = top ?? this.scrollTop - }, - }) +describe("locked native iOS deferred adjustment controls, with simulated DOM geometry", () => { + it("does not clear deferred prepend compensation with an ordinary user handover offset", () => { + const h = mount() + h.move(500) + const offset = vi.spyOn(scrollFixture.latest.virtualizer, "scrollToOffset") + fireEvent.touchStart(h.root, { touches: [{ clientY: 300 }] }) + const before = h.root.scrollTop + h.update({ items: [message("older"), ...h.input.items] }) + expect(offset).not.toHaveBeenCalled() + expect(h.root.scrollTop).toBe(before) + fireEvent.touchEnd(h.root, { touches: [] }) + runFrames() + expect(h.root.scrollTop).toBeGreaterThan(before) + expect(offset).not.toHaveBeenCalled() }) - - afterEach(() => { - vi.restoreAllMocks() - vi.unstubAllGlobals() - if (scrollToDescriptor) { - Object.defineProperty(HTMLElement.prototype, "scrollTo", scrollToDescriptor) - } else { - delete HTMLElement.prototype.scrollTo - } + it("lets native flush pinned long-row growth before accepting a new prefix baseline", () => { + scrollFixture.bodyHeights.set("m0", 1800) + const h = mount({ items: [message("m0")] }) + const offset = vi.spyOn(scrollFixture.latest.virtualizer, "scrollToOffset") + const before = h.root.scrollTop + fireEvent.touchStart(h.root, { touches: [{ clientY: 200 }] }) + scrollFixture.firstPrefix += 60 + h.update({ items: [...h.input.items] }) + expect(h.root.scrollTop).toBe(before) + expect(offset).not.toHaveBeenCalled() + fireEvent.touchEnd(h.root, { touches: [] }) + runFrames() + expect(h.root.scrollTop - before).toBe(60) + expect(offset).not.toHaveBeenCalled() }) - - it("shoves scrollTop down by the delta when the hero grows between renders", () => { - const rendered = render(createElement(Harness, { heroHeight: 0 })) - const scroll = screen.getByTestId("scroll") - scroll.scrollTop = 200 - - rendered.rerender(createElement(Harness, { heroHeight: 96 })) - - expect(scroll.scrollTop).toBe(296) + it("drops an old prefix candidate when a new gesture chooses a different reading offset", () => { + scrollFixture.bodyHeights.set("m0", 1200) + const h = mount() + h.move(400) + const offset = vi.spyOn(scrollFixture.latest.virtualizer, "scrollToOffset") + fireEvent.touchStart(h.root, { touches: [{ clientY: 200 }] }) + scrollFixture.firstPrefix += 40 + h.update({ items: [...h.input.items] }) + fireEvent.wheel(h.root, { deltaY: 40 }) + act(() => h.root.scrollTo({ top: 600 })) + fireEvent.touchEnd(h.root, { touches: [] }) + runFrames() + expect(offset).not.toHaveBeenCalled() + expect(h.root.scrollTop).toBe(600) + expect(bodyTop(h.root, "m0")).toBeLessThan(0) }) - - it("pulls scrollTop back up by the delta when the hero shrinks between renders", () => { - const rendered = render(createElement(Harness, { heroHeight: 96 })) - const scroll = screen.getByTestId("scroll") - scroll.scrollTop = 300 - - rendered.rerender(createElement(Harness, { heroHeight: 40 })) - - expect(scroll.scrollTop).toBe(244) - }) - - it("does not touch scrollTop when heroHeight is unchanged across a re-render", () => { - const rendered = render(createElement(Harness, { heroHeight: 60 })) - const scroll = screen.getByTestId("scroll") - scroll.scrollTop = 150 - - rendered.rerender(createElement(Harness, { heroHeight: 60 })) - - expect(scroll.scrollTop).toBe(150) + it("yields footer settlement during touch and does not restore an old offset after the gesture", () => { + const h = mount() + h.move(500) + const offset = vi.spyOn(scrollFixture.latest.virtualizer, "scrollToOffset") + fireEvent.touchStart(h.root, { touches: [{ clientY: 200 }] }) + scrollFixture.height -= 100 + resize() + expect(offset).not.toHaveBeenCalled() + fireEvent.touchEnd(h.root, { touches: [] }) + runFrames() + expect(h.root.scrollTop).toBe(500) + expect(offset).not.toHaveBeenCalled() }) }) diff --git a/src/web/src/hooks/community/use-scroll-anchor.pagination.dom.test.ts b/src/web/src/hooks/community/use-scroll-anchor.pagination.dom.test.ts index c5ffbb325..46e99ad5b 100644 --- a/src/web/src/hooks/community/use-scroll-anchor.pagination.dom.test.ts +++ b/src/web/src/hooks/community/use-scroll-anchor.pagination.dom.test.ts @@ -1,368 +1,210 @@ -import { createElement, useLayoutEffect } from "react" -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" -import { act, render, screen } from "@/test/react-dom-harness" -import type { FlatItem } from "@/lib/community/message-list-items" - -const harness = vi.hoisted(() => ({ - absoluteTops: new Map(), - scroller: null as HTMLDivElement | null, - scrollToIndex: vi.fn((index: number) => { - if (!harness.scroller) return - const row = harness.scroller.querySelector(`[data-index="${index}"]`) - const id = row?.dataset.msgId - if (id) harness.scroller.scrollTop = harness.absoluteTops.get(id) ?? 0 - }), - virtualizer: { - options: { anchorTo: "end" }, - isAtEnd: vi.fn(() => false), - scrollToEnd: vi.fn(), - scrollToIndex: vi.fn(), - range: null, - shouldAdjustScrollPositionOnItemSizeChange: undefined, - }, -})) - -harness.virtualizer.scrollToIndex = harness.scrollToIndex - -vi.mock("@tanstack/react-virtual", () => ({ - useVirtualizer: () => harness.virtualizer, -})) - -import { useScrollAnchor } from "./use-scroll-anchor" - -type AnchorResult = ReturnType - -function message(id: string): FlatItem { - return { - kind: "message", - m: { id, type: "chat", grouped: false }, - key: `msg:${id}`, - } -} - -const day = (key: string): FlatItem => ({ kind: "date-divider", label: key, key: `date:${key}` }) - -function Harness({ - items, - isFetchingOlder, - isFetchingNewer = false, - onResult, -}: { - items: FlatItem[] - isFetchingOlder: boolean - isFetchingNewer?: boolean - onResult: (result: AnchorResult) => void -}) { - const result = useScrollAnchor({ - items, - initialScrollReady: false, - isFetchingOlder, - isFetchingNewer, - heroHeight: 0, - heroMeasured: false, +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest" +import { act, fireEvent } from "@/test/react-dom-harness" +import { bodyTop, installMessageScrollFixture, restoreMessageScrollFixture, message, mount, runFrames, scrollFixture } from "@/test/message-scroll-fixture" + +beforeEach(installMessageScrollFixture) +afterEach(restoreMessageScrollFixture) + +describe("native pagination and original semantic epoch", () => { + it("restores initial New intent and bounded settling after StrictMode effect replay", () => { + const shown = vi.fn() + const items = Array.from({ length: 14 }, (_, i) => ({ ...message(`m${i}`), newDivider: i === 4 })) + const h = mount({ items, newDividerBefore: "m4", onInitialPositionSettled: shown }, true) + const marker = h.root.querySelector('[data-new-divider]')!.getBoundingClientRect() + expect((marker.top + marker.bottom) / 2).toBeCloseTo(scrollFixture.height / 2, 0) + expect(scrollFixture.latest.readPositionReady).toBe(true) + expect(shown).toHaveBeenCalledOnce() }) - useLayoutEffect(() => onResult(result), [onResult, result]) - return createElement( - "div", - { ref: result.scrollRef, "data-testid": "scroll" }, - ...items.map((item, index) => createElement("div", { - key: item.key, - "data-index": index, - "data-msg-id": item.kind === "message" ? item.m.id : undefined, - })), - ) -} - -describe("useScrollAnchor older-page message anchoring", () => { - let frames: FrameRequestCallback[] - let resizeCallbacks: ResizeObserverCallback[] - let clientHeight: number - let cancelFrame: ReturnType - let latest: AnchorResult - - beforeEach(() => { - frames = [] - resizeCallbacks = [] - clientHeight = 600 - harness.absoluteTops.clear() - harness.scroller = null - harness.scrollToIndex.mockClear() - harness.virtualizer.scrollToEnd.mockClear() - harness.virtualizer.options.anchorTo = "end" - cancelFrame = vi.fn() - vi.stubGlobal("ResizeObserver", class { - constructor(callback: ResizeObserverCallback) { - resizeCallbacks.push(callback) - } - - observe() {} - disconnect() {} - }) - vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { - frames.push(callback) - return frames.length - }) - vi.spyOn(window, "cancelAnimationFrame").mockImplementation(cancelFrame) - vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockImplementation(() => clientHeight) - vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(5_000) - vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function () { - const element = this as HTMLElement - if (element.dataset.testid === "scroll") { - return DOMRect.fromRect({ y: 0, height: 600 }) - } - const id = element.dataset.msgId - const top = id ? (harness.absoluteTops.get(id) ?? 0) - (harness.scroller?.scrollTop ?? 0) : 0 - return DOMRect.fromRect({ y: top, height: 80 }) + it("restores an explicit target after StrictMode replay and consumes only its current proof", () => { + const consumed = vi.fn() + const h = mount({ scrollToMessageId: "m4", onScrollTargetPositioned: consumed }, true) + expect(consumed).toHaveBeenCalledExactlyOnceWith("m4") + expect(scrollFixture.latest.readPositionReady).toBe(true) + expect(bodyTop(h.root, "m4")).toBeGreaterThanOrEqual(0) + expect(bodyTop(h.root, "m4")).toBeLessThan(scrollFixture.height) + }) + it("rebinds a pending Present requested by a layout consumer across StrictMode replay", () => { + let requested = false + const h = mount({ hasMoreNewer: true }, true, result => { + if (requested) return + requested = true + result.requestPresentPosition() }) + expect(scrollFixture.latest.readPositionReady).toBe(false) + const end = vi.spyOn(scrollFixture.latest.virtualizer, "scrollToEnd") + h.update({ hasMoreNewer: false, presentVersion: 1, items: [...h.input.items, message("current-tail")] }) + expect(end).toHaveBeenCalledOnce() + expect(scrollFixture.latest.readPositionReady).toBe(true) + expect(h.root.scrollTop).toBe(h.root.scrollHeight - scrollFixture.height) }) - - afterEach(() => { - vi.restoreAllMocks() - vi.unstubAllGlobals() + it("uses native same-key preservation for newer append and head trim", () => { + const h = mount() + h.move(650) + const before = bodyTop(h.root, "m7") + const offset = vi.spyOn(scrollFixture.latest.virtualizer, "scrollToOffset") + const index = vi.spyOn(scrollFixture.latest.virtualizer, "scrollToIndex") + act(() => scrollFixture.latest.captureNewerPageAnchor()) + h.update({ isFetchingNewer: true }) + h.update({ items: [...h.input.items.slice(2), message("newer1"), message("newer2")], isFetchingNewer: false }) + expect(bodyTop(h.root, "m7")).toBeCloseTo(before, 0) + expect(offset).not.toHaveBeenCalled() + expect(index).not.toHaveBeenCalled() + expect(scrollFixture.latest.isNewerPageAnchorSettling).toBe(false) }) - - function runCase(initialItems: FlatItem[], nextItems: FlatItem[], nextAnchorTop: number) { - const onResult = (result: AnchorResult) => { latest = result } - harness.absoluteTops.set("anchor", 120) - const rendered = render(createElement(Harness, { - items: initialItems, - isFetchingOlder: false, - onResult, - })) - harness.scroller = screen.getByTestId("scroll") as HTMLDivElement - - act(() => latest.captureOlderPageAnchor()) - rendered.rerender(createElement(Harness, { - items: initialItems, - isFetchingOlder: true, - onResult, - })) - harness.absoluteTops.set("anchor", nextAnchorTop) - rendered.rerender(createElement(Harness, { - items: nextItems, - isFetchingOlder: false, - onResult, - })) - act(() => { - while (frames.length > 0) frames.shift()!(0) - }) - - const anchor = rendered.container.querySelector('[data-msg-id="anchor"]')! - expect(anchor.getBoundingClientRect().top).toBe(120) - expect(latest.isOlderPageAnchorSettling).toBe(false) - return rendered - } - - function runNewerCase(initialItems: FlatItem[], nextItems: FlatItem[], nextAnchorTop: number) { - const onResult = (result: AnchorResult) => { latest = result } - harness.absoluteTops.set("anchor", 120) - const rendered = render(createElement(Harness, { - items: initialItems, - isFetchingOlder: false, - onResult, - })) - harness.scroller = screen.getByTestId("scroll") as HTMLDivElement - - act(() => latest.captureNewerPageAnchor()) - expect(latest.isNewerPageAnchorSettling).toBe(true) - expect(harness.virtualizer.options.anchorTo).toBe("start") - rendered.rerender(createElement(Harness, { - items: initialItems, - isFetchingOlder: false, - isFetchingNewer: true, - onResult, - })) - harness.scroller.scrollTop = 96 - harness.scroller.dispatchEvent(new Event("scroll")) - harness.absoluteTops.set("anchor", nextAnchorTop) - rendered.rerender(createElement(Harness, { - items: nextItems, - isFetchingOlder: false, - isFetchingNewer: false, - onResult, - })) - act(() => { - while (frames.length > 0) frames.shift()!(0) - }) - - const anchor = rendered.container.querySelector('[data-msg-id="anchor"]')! - expect(anchor.getBoundingClientRect().top).toBe(24) - expect(latest.isNewerPageAnchorSettling).toBe(false) - return rendered - } - - it("preserves the real message across same-day and cross-day divider shapes", () => { - const initial = [day("2026-09-18"), message("anchor")] - const sameDay = [day("2026-09-18"), message("older"), message("anchor")] - let rendered = runCase(initial, sameDay, 520) - expect(harness.scrollToIndex).toHaveBeenLastCalledWith(2, { align: "start" }) - rendered.unmount() - - harness.scrollToIndex.mockClear() - const crossDay = [ - day("2026-09-17"), - message("older"), - day("2026-09-18"), - message("anchor"), - ] - rendered = runCase(initial, crossDay, 720) - expect(harness.scrollToIndex).toHaveBeenLastCalledWith(3, { align: "start" }) - rendered.unmount() + it("does not treat a divider update as observed fetch completion", () => { + const h = mount() + act(() => scrollFixture.latest.captureOlderPageAnchor()) + h.update({ items: [...h.input.items] }) + expect(scrollFixture.latest.isOlderPageAnchorSettling).toBe(true) + h.update({ isFetchingOlder: true }) + h.update({ isFetchingOlder: false }) + expect(scrollFixture.latest.isOlderPageAnchorSettling).toBe(false) }) - - it("settles an empty or fully overlapping page without moving the anchor", () => { - const items = [day("2026-09-18"), message("anchor")] - const rendered = runCase(items, items.slice(), 120) - expect(harness.scrollToIndex).toHaveBeenLastCalledWith(1, { align: "start" }) - rendered.unmount() + it("finishes a replacement window without inventing preservation of a removed key or following its historical tail", () => { + const h = mount({ viewerUserId: "me" }) + h.move(500) + const end = vi.spyOn(scrollFixture.latest.virtualizer, "scrollToEnd") + act(() => scrollFixture.latest.captureNewerPageAnchor()) + h.update({ isFetchingNewer: true }) + h.update({ items: Array.from({ length: 12 }, (_, i) => message(`replacement${i}`, "me")), isFetchingNewer: false }) + expect(scrollFixture.latest.isNewerPageAnchorSettling).toBe(false) + expect(h.root.querySelector('[data-msg-id="m5"]')).toBeNull() + expect(end).not.toHaveBeenCalled() }) - - it("keeps a settled older-page anchor through the following viewport resize", () => { - const initial = [day("2026-09-18"), message("anchor")] - const prepended = [day("2026-09-18"), message("older"), message("anchor")] - const rendered = runCase(initial, prepended, 520) - const anchor = rendered.container.querySelector('[data-msg-id="anchor"]')! - - expect(harness.scroller!.scrollTop).toBe(400) - clientHeight = 500 - act(() => resizeCallbacks.at(-1)?.([], {} as ResizeObserver)) - - expect(harness.scroller!.scrollTop).toBe(400) - expect(anchor.getBoundingClientRect().top).toBe(120) - rendered.unmount() + it("keeps the original initial New intent while an older page moves its numeric index", () => { + const items = Array.from({ length: 14 }, (_, i) => ({ ...message(`m${i}`), newDivider: i === 4 })) + const h = mount({ items, newDividerBefore: "m4", initialScrollReady: false, hasMoreNewer: true }) + act(() => scrollFixture.latest.captureOlderPageAnchor()) + h.update({ isFetchingOlder: true }) + h.update({ items: [message("older"), ...h.input.items], isFetchingOlder: false, initialScrollReady: true }) + const marker = h.root.querySelector('[data-new-divider]')!.getBoundingClientRect() + expect((marker.top + marker.bottom) / 2).toBeCloseTo(scrollFixture.height / 2, 0) + expect(scrollFixture.latest.readPositionReady).toBe(true) }) - - it("preserves the real message while a newer page appends at the loaded-window tail", () => { - const initial = [day("2026-09-18"), message("anchor")] - const appended = [ - day("2026-09-18"), - message("anchor"), - message("newer"), - ] - const rendered = runNewerCase(initial, appended, 120) - expect(harness.scrollToIndex).toHaveBeenLastCalledWith(1, { align: "start" }) - expect(harness.virtualizer.scrollToEnd).not.toHaveBeenCalled() - rendered.unmount() + it("retires the old native index once on replacement and rejects its canceled frame", () => { + const consumed = vi.fn() + const h = mount({ onScrollTargetPositioned: consumed }) + h.stage({ scrollToMessageId: "m12" }) + const stale = [...scrollFixture.frames.values()] + const offset = vi.spyOn(scrollFixture.latest.virtualizer, "scrollToOffset") + h.stage({ scrollToMessageId: "unloaded" }) + expect(offset).toHaveBeenCalledTimes(1) + act(() => stale.forEach(callback => callback(performance.now()))) + expect(consumed).not.toHaveBeenCalled() + h.update({ items: [...h.input.items, message("unloaded")] }) + expect(consumed).toHaveBeenCalledExactlyOnceWith("unloaded") }) - - it("captures newer-page scrollTop even when virtual rows lag the end intersection", () => { - const onResult = (result: AnchorResult) => { latest = result } - harness.absoluteTops.set("anchor", 2_000) - const initial = [message("anchor")] - const rendered = render(createElement(Harness, { - items: initial, - isFetchingOlder: false, - onResult, - })) - harness.scroller = screen.getByTestId("scroll") as HTMLDivElement - harness.scroller.scrollTop = 300 - - act(() => latest.captureNewerPageAnchor()) - expect(latest.isNewerPageAnchorSettling).toBe(true) - rendered.rerender(createElement(Harness, { - items: initial, - isFetchingOlder: false, - isFetchingNewer: true, - onResult, - })) - harness.scroller.scrollTop = 1_000 - harness.scroller.dispatchEvent(new Event("scroll")) - harness.scroller.scrollTop = 1_400 - rendered.rerender(createElement(Harness, { - items: [...initial, message("newer")], - isFetchingOlder: false, - isFetchingNewer: false, - onResult, - })) - expect(harness.scroller.scrollTop).toBe(1_000) - // A virtualizer/browser write can land between the synchronous restore - // and its first reconciliation frame. The numeric fallback must repair - // that drift even though no real row was visible at capture time. - harness.scroller.scrollTop = 1_400 - act(() => { - while (frames.length > 0) frames.shift()!(0) - }) - - expect(harness.scroller.scrollTop).toBe(1_000) - expect(latest.isNewerPageAnchorSettling).toBe(false) - rendered.unmount() + it("cancels an owned native index on user takeover without canceling ordinary native compensation", () => { + const h = mount() + h.stage({ scrollToMessageId: "m12" }) + const offset = vi.spyOn(scrollFixture.latest.virtualizer, "scrollToOffset") + fireEvent.wheel(h.root, { deltaY: -20 }) + expect(offset).toHaveBeenCalledTimes(1) + runFrames() + offset.mockClear() + fireEvent.wheel(h.root, { deltaY: -20 }) + runFrames() + expect(offset).not.toHaveBeenCalled() }) - - it("cancels a pending reconciliation before capturing a replacement anchor", () => { - const onResult = (result: AnchorResult) => { latest = result } - const initial = [message("anchor")] - const prepended = [message("older"), message("anchor")] - harness.absoluteTops.set("anchor", 120) - const rendered = render(createElement(Harness, { - items: initial, - isFetchingOlder: false, - onResult, - })) - harness.scroller = screen.getByTestId("scroll") as HTMLDivElement - - act(() => latest.captureOlderPageAnchor()) - rendered.rerender(createElement(Harness, { items: initial, isFetchingOlder: true, onResult })) - harness.absoluteTops.set("anchor", 520) - rendered.rerender(createElement(Harness, { items: prepended, isFetchingOlder: false, onResult })) - expect(frames).toHaveLength(1) - - act(() => latest.captureOlderPageAnchor()) - expect(cancelFrame).toHaveBeenCalledWith(1) - expect(latest.isOlderPageAnchorSettling).toBe(true) - rendered.unmount() + it("does not consume a previously completed external target when canceling a local reply jump", () => { + const consumed = vi.fn() + const cancelled = vi.fn() + const h = mount({ onScrollTargetPositioned: consumed, onScrollTargetCancelled: cancelled }) + h.update({ scrollToMessageId: "m4" }) + expect(consumed).toHaveBeenCalledExactlyOnceWith("m4") + act(() => scrollFixture.latest.jumpTo("m10", "auto")) + expect(scrollFixture.latest.readPositionReady).toBe(false) + fireEvent.wheel(h.root, { deltaY: -20 }) + runFrames() + expect(cancelled).not.toHaveBeenCalled() + expect(consumed).toHaveBeenCalledOnce() }) - - it("clears settlement when the captured anchor disappears from the loaded window", () => { - const onResult = (result: AnchorResult) => { latest = result } - const initial = [message("anchor")] - harness.absoluteTops.set("anchor", 120) - const rendered = render(createElement(Harness, { - items: initial, - isFetchingOlder: false, - onResult, - })) - harness.scroller = screen.getByTestId("scroll") as HTMLDivElement - - act(() => latest.captureOlderPageAnchor()) - rendered.rerender(createElement(Harness, { items: initial, isFetchingOlder: true, onResult })) - rendered.rerender(createElement(Harness, { - items: [day("2026-09-17")], - isFetchingOlder: false, - onResult, - })) - - expect(harness.scrollToIndex).not.toHaveBeenCalled() - expect(latest.isOlderPageAnchorSettling).toBe(false) - rendered.unmount() + it("retires pending frames and target callbacks on actual unmount", () => { + const consumed = vi.fn() + const shown = vi.fn() + const h = mount({ onScrollTargetPositioned: consumed, onInitialPositionSettled: shown }) + shown.mockClear() + h.stage({ scrollToMessageId: "m12" }) + const stale = [...scrollFixture.frames.values()] + h.view.unmount() + act(() => stale.forEach(callback => callback(performance.now()))) + act(() => vi.advanceTimersByTime(2500)) + expect(consumed).not.toHaveBeenCalled() + expect(shown).not.toHaveBeenCalled() }) - - it("cancels pending reconciliation on effect replacement and unmount", () => { - const onResult = (result: AnchorResult) => { latest = result } - const initial = [message("anchor")] - const prepended = [message("older"), message("anchor")] - harness.absoluteTops.set("anchor", 120) - const rendered = render(createElement(Harness, { - items: initial, - isFetchingOlder: false, - onResult, - })) - harness.scroller = screen.getByTestId("scroll") as HTMLDivElement - - act(() => latest.captureOlderPageAnchor()) - rendered.rerender(createElement(Harness, { items: initial, isFetchingOlder: true, onResult })) - harness.absoluteTops.set("anchor", 520) - rendered.rerender(createElement(Harness, { items: prepended, isFetchingOlder: false, onResult })) - expect(frames).toHaveLength(1) - - rendered.rerender(createElement(Harness, { - items: prepended.slice(), - isFetchingOlder: false, - onResult, - })) - expect(cancelFrame).toHaveBeenCalledWith(1) - expect(frames).toHaveLength(2) - - rendered.unmount() - expect(cancelFrame).toHaveBeenCalledWith(2) + it("retires an unsettled native end request on user takeover before late tail measurements", () => { + const h = mount() + const offset = vi.spyOn(scrollFixture.latest.virtualizer, "scrollToOffset") + act(() => scrollFixture.latest.scrollToBottom()) + fireEvent.wheel(h.root, { deltaY: -20 }) + expect(offset).toHaveBeenCalledOnce() + act(() => h.root.scrollTo({ top: 300 })) + runFrames() + scrollFixture.bodyHeights.set("m13", 240) + h.update({ items: [...h.input.items] }) + expect(h.root.scrollTop).toBe(300) + expect(offset).toHaveBeenCalledOnce() + }) + it.each(["target", "end"] as const)("retires an unsettled native %s request on keyboard takeover before late geometry", kind => { + const consumed = vi.fn() + const cancelled = vi.fn() + const h = mount({ onScrollTargetPositioned: consumed, onScrollTargetCancelled: cancelled }) + if (kind === "target") h.stage({ scrollToMessageId: "m12" }) + else act(() => scrollFixture.latest.scrollToBottom()) + const stale = [...scrollFixture.frames.values()] + const offset = vi.spyOn(scrollFixture.latest.virtualizer, "scrollToOffset") + fireEvent.keyDown(h.root, { key: "Enter" }) + expect(offset).not.toHaveBeenCalled() + fireEvent.keyDown(h.root, { key: "PageUp" }) + expect(offset).toHaveBeenCalledOnce() + act(() => h.root.scrollTo({ top: 300 })) + act(() => stale.forEach(callback => callback(performance.now()))) + runFrames() + scrollFixture.bodyHeights.set("m13", 240) + h.update({ items: [...h.input.items] }) + act(() => vi.advanceTimersByTime(2500)) + runFrames() + expect(h.root.scrollTop).toBe(300) + expect(offset).toHaveBeenCalledOnce() + expect(consumed).not.toHaveBeenCalled() + if (kind === "target") expect(cancelled).toHaveBeenCalledExactlyOnceWith("m12") + else expect(cancelled).not.toHaveBeenCalled() + expect(scrollFixture.latest.readPositionReady).toBe(true) + }) + it("replaces an unsettled native end request immediately while the new target is absent", () => { + const h = mount() + const offset = vi.spyOn(scrollFixture.latest.virtualizer, "scrollToOffset") + act(() => scrollFixture.latest.scrollToBottom()) + h.stage({ scrollToMessageId: "unloaded" }) + const before = h.root.scrollTop + expect(offset).toHaveBeenCalledOnce() + scrollFixture.bodyHeights.set("m13", 240) + h.update({ items: [...h.input.items] }) + expect(h.root.scrollTop).toBe(before) + expect(offset).toHaveBeenCalledOnce() + expect(scrollFixture.latest.readPositionReady).toBe(false) + }) + it("waits for an absent explicit target, closes read, and finishes through its finite deadline", () => { + const consumed = vi.fn() + const h = mount({ onScrollTargetPositioned: consumed }) + h.update({ scrollToMessageId: "not-loaded" }) + expect(scrollFixture.latest.readPositionReady).toBe(false) + expect(consumed).not.toHaveBeenCalled() + act(() => vi.advanceTimersByTime(2000)) + expect(scrollFixture.latest.readPositionReady).toBe(false) + h.update({ items: [...h.input.items, message("not-loaded")] }) + expect(consumed).not.toHaveBeenCalled() + }) + it("opens an explicit target only after its actual body geometry and ignores target data arriving after user takeover", () => { + const consumed = vi.fn() + const h = mount({ onScrollTargetPositioned: consumed }) + h.update({ scrollToMessageId: "m4" }) + expect(consumed).toHaveBeenCalledWith("m4") + expect(scrollFixture.latest.readPositionReady).toBe(true) + h.update({ scrollToMessageId: "unloaded" }) + fireEvent.wheel(h.root, { deltaY: -20 }) + runFrames() + h.update({ items: [...h.input.items, message("unloaded")] }) + expect(consumed).toHaveBeenCalledTimes(1) }) }) diff --git a/src/web/src/hooks/community/use-scroll-anchor.repin.dom.test.ts b/src/web/src/hooks/community/use-scroll-anchor.repin.dom.test.ts index a98d2dadd..0296b153a 100644 --- a/src/web/src/hooks/community/use-scroll-anchor.repin.dom.test.ts +++ b/src/web/src/hooks/community/use-scroll-anchor.repin.dom.test.ts @@ -1,600 +1,535 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" -import type { VirtualizerOptions } from "@tanstack/react-virtual" -import type { FlatItem } from "@/lib/community/message-list-items" -import { createElement, useLayoutEffect } from "react" -import { act, render } from "@/test/react-dom-harness" - -let resizeCallbacks: ResizeObserverCallback[] = [] -let mutationCallbacks: MutationCallback[] = [] -const nativeWindow = window -function stubWindow(options: Partial) { - vi.stubGlobal("window", new Proxy(nativeWindow, { get(target, key) { - return key in options ? Reflect.get(options, key) : Reflect.get(target, key, target) - } })) -} - -const virtualizer = { - options: { anchorTo: "end" }, - isAtEnd: vi.fn(() => true), - scrollToEnd: vi.fn(), - scrollToIndex: vi.fn(), - range: null, - shouldAdjustScrollPositionOnItemSizeChange: undefined, -} -let virtualizerOptions: VirtualizerOptions | undefined - -vi.mock("@tanstack/react-virtual", () => ({ - useVirtualizer: (options: VirtualizerOptions) => { - virtualizerOptions = options - return virtualizer - }, -})) - -function resetHarness() { - resizeCallbacks = [] - mutationCallbacks = [] - virtualizerOptions = undefined - virtualizer.options.anchorTo = "end" - virtualizer.isAtEnd.mockReturnValue(true) - virtualizer.scrollToEnd.mockReset() - virtualizer.scrollToIndex.mockReset() - stubWindow({ - requestAnimationFrame: vi.fn(() => 1), - cancelAnimationFrame: vi.fn(), - }) - vi.stubGlobal("ResizeObserver", class { - constructor(callback: ResizeObserverCallback) { - resizeCallbacks.push(callback) - } - - observe() {} - unobserve() {} - disconnect() {} +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest" +import { act, fireEvent } from "@/test/react-dom-harness" +import { bodyTop, installMessageScrollFixture, restoreMessageScrollFixture, message, mount, resize, runFrames, scrollFixture } from "@/test/message-scroll-fixture" + +beforeEach(installMessageScrollFixture) +afterEach(restoreMessageScrollFixture) + +describe("locked native adapter and existing message scroll owner", () => { + it("unifies native total and DOM max while keeping 40/48px rail clearance", () => { + const h = mount() + expect(scrollFixture.latest.readPositionReady).toBe(true) + expect(scrollFixture.latest.virtualizer.options).toMatchObject({ anchorTo: "end", followOnAppend: false, scrollEndThreshold: 1, scrollMargin: 0, paddingEnd: 48 }) + expect(h.root.scrollHeight).toBe(scrollFixture.latest.virtualizer.getTotalSize()) + expect(h.root.scrollTop).toBe(h.root.scrollHeight - scrollFixture.height) + h.update({ tailPaddingEnd: 40 }) + expect(h.root.scrollHeight).toBe(scrollFixture.latest.virtualizer.getTotalSize()) }) - vi.stubGlobal("MutationObserver", class { - constructor(callback: MutationCallback) { - mutationCallbacks.push(callback) - } - - observe() {} - disconnect() {} - takeRecords() { return [] } + it("centers the actual New prefix of a long message with one business correction", () => { + scrollFixture.bodyHeights.set("m4", 1800) + const items = Array.from({ length: 12 }, (_, i) => ({ ...message(`m${i}`), ...(i === 4 ? { newDivider: true } : {}) })) + const h = mount({ items, newDividerBefore: "m4", hasMoreNewer: true }) + const marker = h.root.querySelector('[data-new-divider]')!.getBoundingClientRect() + expect((marker.top + marker.bottom) / 2).toBeCloseTo(scrollFixture.height / 2, 0) + expect(bodyTop(h.root, "m4")).toBeLessThan(scrollFixture.height) + expect(scrollFixture.latest.readPositionReady).toBe(true) }) -} - -async function mountHook({ - distanceToEnd = 0, - initialClientHeight = 800, - initialScrollHeight = 1_600, - items = [] as FlatItem[], - initialScrollReady = false, - heroMeasured = false, - newDividerBefore, - hasMoreNewer, - presentVersion, - viewerUserId, - onInitialPositionSettled, - tailPaddingEnd = 48, -}: { - distanceToEnd?: number - initialClientHeight?: number - initialScrollHeight?: number - items?: FlatItem[] - initialScrollReady?: boolean - heroMeasured?: boolean - newDividerBefore?: string - hasMoreNewer?: boolean - presentVersion?: number - viewerUserId?: string - onInitialPositionSettled?: () => void - tailPaddingEnd?: number -} = {}) { - const { useScrollAnchor } = await import("./use-scroll-anchor") - const hookInput = { - items, - initialScrollReady, - heroHeight: 0, - heroMeasured, - newDividerBefore, - hasMoreNewer, - presentVersion, - viewerUserId, - onInitialPositionSettled, - tailPaddingEnd, - isFetchingOlder: false, - isFetchingNewer: false, - } - const listeners = new Map() - const scrollWrites: number[] = [] - let clientHeight = initialClientHeight - let scrollHeight = initialScrollHeight - let scrollTop = Math.max(0, scrollHeight - clientHeight - distanceToEnd) - const scroller = { - addEventListener: (type: string, listener: EventListener) => listeners.set(type, listener), - removeEventListener: vi.fn(), - closest: () => ({ querySelector: () => ({}) }), - querySelectorAll: () => [], - getBoundingClientRect: () => ({ top: 0, bottom: clientHeight }), - get clientHeight() { - return clientHeight - }, - get scrollHeight() { - return scrollHeight - }, - get scrollTop() { - return scrollTop - }, - set scrollTop(value: number) { - scrollTop = value - scrollWrites.push(value) - }, - } as unknown as HTMLDivElement - virtualizer.scrollToEnd.mockImplementation(() => { - scrollTop = Math.max(0, scrollHeight - clientHeight) + it("clamps a first-row merged date/New boundary to the actual content origin", () => { + scrollFixture.firstPrefix = 152 + scrollFixture.bodyHeights.set("first", 1800) + const item = { ...message("first"), dateLabel: "Today", newDivider: true } + const h = mount({ items: [item], hasMoreOlder: false, newDividerBefore: "first" }) + const marker = h.root.querySelector('[data-new-divider]')!.getBoundingClientRect() + expect(h.root.scrollTop).toBe(0) + expect(marker.top).toBeGreaterThanOrEqual(0) + expect(marker.bottom).toBeLessThan(scrollFixture.height) + expect(bodyTop(h.root, "first")).toBeLessThan(scrollFixture.height) + expect(scrollFixture.latest.readPositionReady).toBe(true) }) - - let result!: ReturnType - function Probe() { - const current = useScrollAnchor(hookInput) - useLayoutEffect(() => { result = current }) - return createElement("div", { ref: (node: HTMLDivElement | null) => { current.scrollRef.current = node ? scroller : null } }) - } - const view = render(createElement(Probe)) - const rerender = (overrides: Partial) => { - Object.assign(hookInput, overrides) - act(() => view.rerender(createElement(Probe))) - return result - } - - const setBrowserScrollTop = (value: number) => { - scrollTop = Math.max(0, Math.min(value, scrollHeight - clientHeight)) - } - const setScrollHeight = (value: number) => { - scrollHeight = value - setBrowserScrollTop(scrollTop) - } - const setClientHeight = (value: number) => { - clientHeight = value - // Browser max-scroll clamping is not a JavaScript policy write. - setBrowserScrollTop(scrollTop) - } - const dispatchScroll = () => listeners.get("scroll")?.(new Event("scroll")) - const dispatchResize = () => resizeCallbacks.at(-1)?.([], {} as ResizeObserver) - const dispatchMutation = () => mutationCallbacks.at(-1)?.([], {} as MutationObserver) - const resizeViewport = ( - nextClientHeight: number, - order: "scroll-ro" | "ro-scroll" = "ro-scroll", - ) => { - setClientHeight(nextClientHeight) - if (order === "scroll-ro") { - dispatchScroll() - dispatchResize() - } else { - dispatchResize() - dispatchScroll() - } - } - const geometry = () => ({ clientHeight, scrollHeight, scrollTop }) - - return { - listeners, - scrollWrites, - scroller, - result, - dispatchScroll, - dispatchMutation, - resizeViewport, - setBrowserScrollTop, - setClientHeight, - setScrollHeight, - geometry, - rerender, - unmount: view.unmount, - } -} - -function messageItem(id: string, authorId?: string): FlatItem { - return { - kind: "message", - m: { id, type: "chat", grouped: false, authorId }, - key: `msg:${id}`, - } -} - -function growingRow(requestFrame: (callback: FrameRequestCallback) => void, index = 0) { - let height = 400 - return { - element: { - dataset: { index: String(index) }, - getBoundingClientRect: () => ({ height }), - get scrollHeight() { - return height - }, - isConnected: true, - ownerDocument: { - defaultView: { - requestAnimationFrame: (callback: FrameRequestCallback) => { - requestFrame(callback) - return 1 - }, - }, - }, - } as unknown as Element, - growTo: (nextHeight: number) => { height = nextHeight }, - } -} - -beforeEach(resetHarness) -afterEach(() => { vi.unstubAllGlobals() }) - -describe("useScrollAnchor delayed row-growth re-pin", () => { - it("puts the fixed rail clearance inside the virtual total", async () => { - await mountHook({ tailPaddingEnd: 40 }) - expect(virtualizerOptions?.paddingEnd).toBe(40) + it("shows after the original deadline while keeping read and peer follow closed", () => { + const shown = vi.fn() + const h = mount({ initialScrollReady: false, hasMoreNewer: true, onInitialPositionSettled: shown }) + act(() => vi.advanceTimersByTime(2000)) + runFrames() + expect(shown).toHaveBeenCalledOnce() + expect(scrollFixture.latest.readPositionReady).toBe(false) + expect(scrollFixture.latest.virtualizer.options.anchorTo).toBe("start") + const before = h.root.scrollTop + h.update({ items: [...h.input.items, message("late-peer")] }) + expect(h.root.scrollTop).toBe(before) + expect(scrollFixture.latest.readPositionReady).toBe(false) + h.move(300) + expect(scrollFixture.latest.readPositionReady).toBe(true) }) - - it("cancels a pending older-page frame from the unmount fallback", async () => { - stubWindow({ requestAnimationFrame: vi.fn(() => 42), cancelAnimationFrame: vi.fn() }) - const mounted = await mountHook({ items: [messageItem("m1")] }) - vi.spyOn(mounted.scroller, "querySelectorAll").mockReturnValue([{ dataset: { msgId: "m1" }, getBoundingClientRect: () => ({ top: 20, bottom: 40 }) }] as never) - act(() => mounted.result.captureOlderPageAnchor()) - mounted.rerender({ isFetchingOlder: true }) - const current = mounted.rerender({ isFetchingOlder: false }) - expect(current.isOlderPageAnchorSettling).toBe(true) - mounted.unmount() - expect(window.cancelAnimationFrame).toHaveBeenCalledWith(42) + it("does not let pagination retire an unresolved initial intent or its deadline", () => { + const shown = vi.fn() + const h = mount({ initialScrollReady: false, hasMoreNewer: true, onInitialPositionSettled: shown }) + act(() => scrollFixture.latest.captureOlderPageAnchor()) + h.update({ isFetchingOlder: true }) + h.update({ isFetchingOlder: false }) + expect(scrollFixture.latest.virtualizer.options.anchorTo).toBe("start") + act(() => vi.advanceTimersByTime(2000)) + expect(shown).toHaveBeenCalledOnce() + expect(scrollFixture.latest.readPositionReady).toBe(false) }) - - it("publishes settlement one frame after the final initial convergence action", async () => { - const frameCallbacks: FrameRequestCallback[] = [] - const cancelFrame = vi.fn() - const settled = vi.fn() - stubWindow({ - requestAnimationFrame: (callback: FrameRequestCallback) => { - frameCallbacks.push(callback) - return frameCallbacks.length - }, - cancelAnimationFrame: cancelFrame, - }) - - const mounted = await mountHook({ - items: [messageItem("m1")], - initialScrollReady: false, - heroMeasured: true, - onInitialPositionSettled: settled, - }) - expect(virtualizer.scrollToEnd).toHaveBeenCalledOnce() - expect(frameCallbacks).toHaveLength(0) - expect(settled).not.toHaveBeenCalled() - - mounted.rerender({ - initialScrollReady: true, - newDividerBefore: "m1", - }) - expect(virtualizer.scrollToIndex).toHaveBeenCalledOnce() - expect(frameCallbacks).toHaveLength(1) - expect(settled).not.toHaveBeenCalled() - - frameCallbacks[0](0) - expect(settled).toHaveBeenCalledOnce() - mounted.rerender({ newDividerBefore: undefined }) - expect(frameCallbacks).toHaveLength(1) - expect(settled).toHaveBeenCalledOnce() + it("uses stable native keys for ordinary prepend without a business index restore", () => { + const h = mount() + h.move(500) + const top = bodyTop(h.root, "m5") + const index = vi.spyOn(scrollFixture.latest.virtualizer, "scrollToIndex") + const offset = vi.spyOn(scrollFixture.latest.virtualizer, "scrollToOffset") + act(() => scrollFixture.latest.captureOlderPageAnchor()) + h.update({ isFetchingOlder: true }) + h.update({ items: [message("older"), ...h.input.items], isFetchingOlder: false }) + expect(bodyTop(h.root, "m5")).toBeCloseTo(top, 0) + expect(index).not.toHaveBeenCalled() + expect(offset).not.toHaveBeenCalled() + expect(scrollFixture.latest.isOlderPageAnchorSettling).toBe(false) }) - - it("measures live growth and re-pins after the direct-DOM size write settles", async () => { - const { scrollWrites } = await mountHook() - let frame: FrameRequestCallback | undefined - const row = growingRow((callback) => { frame = callback }) - const measure = virtualizerOptions?.measureElement - expect(measure).toBeTypeOf("function") - - expect(measure!(row.element, undefined, virtualizer as never)).toBe(400) - row.growTo(930.4) - expect(measure!(row.element, undefined, virtualizer as never)).toBe(931) - expect(scrollWrites).toEqual([]) - - await Promise.resolve() - expect(scrollWrites).toEqual([1_600]) - expect(frame).toBeTypeOf("function") - - frame!(0) - expect(scrollWrites).toEqual([1_600, 1_600]) + it("repairs only the current message prefix when an old first row loses its header", () => { + scrollFixture.bodyHeights.set("m0", 1200) + const h = mount() + h.move(400) + const top = bodyTop(h.root, "m0") + const offset = vi.spyOn(scrollFixture.latest.virtualizer, "scrollToOffset") + h.update({ items: [message("older"), ...h.input.items] }) + expect(bodyTop(h.root, "m0")).toBeCloseTo(top, 0) + expect(offset).toHaveBeenCalledTimes(1) }) - - it("settles a pinned tail after an appended row's first real measurement", async () => { - const settleFrames: FrameRequestCallback[] = [] - stubWindow({ - requestAnimationFrame: (callback: FrameRequestCallback) => { - settleFrames.push(callback) - return settleFrames.length - }, - cancelAnimationFrame: vi.fn(), - }) - const mounted = await mountHook({ - items: [messageItem("m1")], - initialScrollReady: true, - heroMeasured: true, - }) - expect(settleFrames).toHaveLength(1) - settleFrames[0](0) - mounted.rerender({ items: [messageItem("m1"), messageItem("m2")] }) - - let frame: FrameRequestCallback | undefined - const row = growingRow((callback) => { frame = callback }, 1) - const measure = virtualizerOptions?.measureElement - - expect(measure!(row.element, undefined, virtualizer as never)).toBe(400) - expect(mounted.scrollWrites).toEqual([]) - - await Promise.resolve() - expect(mounted.scrollWrites).toEqual([1_600]) - expect(frame).toBeTypeOf("function") - - frame!(0) - expect(mounted.scrollWrites).toEqual([1_600, 1_600]) + it.each(["wheel", "programmatic"])("retains the current first-row body when prepend finishes before %s scroll quiet", input => { + scrollFixture.bodyHeights.set("m0", 1200) + const h = mount() + if (input === "wheel") fireEvent.wheel(h.root, { deltaY: -20 }) + act(() => h.root.scrollTo({ top: 400 })) + runFrames(2) + const top = bodyTop(h.root, "m0") + const offset = vi.spyOn(scrollFixture.latest.virtualizer, "scrollToOffset") + act(() => scrollFixture.latest.captureOlderPageAnchor()) + h.stage({ isFetchingOlder: true }) + h.update({ items: [message("older"), ...h.input.items], isFetchingOlder: false }) + expect(bodyTop(h.root, "m0")).toBeCloseTo(top, 0) + expect(offset).toHaveBeenCalledOnce() }) - - it("does not treat an anchored mount row as an append before initial positioning settles", async () => { - const settleFrames: FrameRequestCallback[] = [] - stubWindow({ - requestAnimationFrame: (callback: FrameRequestCallback) => { - settleFrames.push(callback) - return settleFrames.length - }, - cancelAnimationFrame: vi.fn(), - }) - const mounted = await mountHook({ - items: [messageItem("anchor"), messageItem("newer")], - initialScrollReady: true, - heroMeasured: true, - newDividerBefore: "anchor", - }) - let frame: FrameRequestCallback | undefined - const row = growingRow((callback) => { frame = callback }, 1) - const measure = virtualizerOptions?.measureElement - - expect(settleFrames).toHaveLength(1) - expect(measure!(row.element, undefined, virtualizer as never)).toBe(400) - await Promise.resolve() - - expect(mounted.scrollWrites).toEqual([]) - expect(frame).toBeUndefined() + it.each(["peer", "me"])("follows a genuine %s append through later native tail measurement", author => { + const h = mount({ viewerUserId: "me" }) + const top = author === "peer" ? h.root.scrollHeight - scrollFixture.height : 400 + h.move(top) + h.stage({ items: [...h.input.items, message("new-tail", author)] }) + runFrames(2) + scrollFixture.bodyHeights.set("new-tail", 456) + resize() + expect(h.root.scrollHeight - h.root.clientHeight - h.root.scrollTop).toBeLessThanOrEqual(1) + expect(scrollFixture.latest.readPositionReady).toBe(true) }) - - it("does not schedule a re-pin after upward user intent", async () => { - const { listeners, scrollWrites } = await mountHook() - let frame: FrameRequestCallback | undefined - const row = growingRow((callback) => { frame = callback }) - const measure = virtualizerOptions?.measureElement - - listeners.get("wheel")?.({ deltaY: -1 } as WheelEvent) - expect(measure!(row.element, undefined, virtualizer as never)).toBe(400) - row.growTo(780) - expect(measure!(row.element, undefined, virtualizer as never)).toBe(780) - await Promise.resolve() - - expect(scrollWrites).toEqual([]) - expect(frame).toBeUndefined() + it.each(["peer", "me"])("keeps a pending %s end intent on the current tail when another row arrives", author => { + const h = mount({ viewerUserId: "me" }) + h.move(author === "peer" ? h.root.scrollHeight - scrollFixture.height : 400) + h.stage({ items: [...h.input.items, message("first-tail", author)] }) + h.stage({ items: [...h.input.items, message("second-tail", author)] }) + resize() + expect(h.root.scrollHeight - h.root.clientHeight - h.root.scrollTop).toBeLessThanOrEqual(1) + expect(scrollFixture.latest.readPositionReady).toBe(true) }) - - it("switches live resize anchoring with scroll and keyboard intent", async () => { - const { listeners, scroller } = await mountHook() - expect(virtualizer.options.anchorTo).toBe("end") - - virtualizer.isAtEnd.mockReturnValue(false) - scroller.scrollTop = 40 - listeners.get("scroll")?.(new Event("scroll")) - expect(virtualizer.options.anchorTo).toBe("start") - - const adjust = virtualizer.shouldAdjustScrollPositionOnItemSizeChange - expect(adjust).toBeTypeOf("function") - expect(adjust!( - { key: "unmeasured", start: 0, end: 20 } as never, - 20, - { - itemSizeCache: new Map(), - scrollAdjustments: 0, - scrollDirection: null, - scrollOffset: 40, - } as never, - )).toBe(false) - - virtualizer.isAtEnd.mockReturnValue(true) - listeners.get("scroll")?.(new Event("scroll")) - expect(virtualizer.options.anchorTo).toBe("start") - - scroller.scrollTop = scroller.scrollHeight - listeners.get("scroll")?.(new Event("scroll")) - expect(virtualizer.options.anchorTo).toBe("end") - - listeners.get("keydown")?.({ key: "PageUp" } as KeyboardEvent) - expect(virtualizer.options.anchorTo).toBe("start") + it("keeps a pending end intent on the current tail after history is prepended", () => { + const h = mount({ viewerUserId: "me" }) + h.move(400) + h.stage({ items: [...h.input.items, message("self-tail", "me")] }) + h.stage({ items: [message("older-one"), message("older-two"), ...h.input.items] }) + resize() + expect(h.root.scrollHeight - h.root.clientHeight - h.root.scrollTop).toBeLessThanOrEqual(1) + expect(scrollFixture.latest.readPositionReady).toBe(true) }) -}) - -const VIEWPORT_RESIZE_BOUNDARIES = [0, 1, 2, 99, 100, 101, 300] - -describe("useScrollAnchor semantic viewport resize anchoring", () => { - it("ignores a ResizeObserver delivery when the viewport height is unchanged", async () => { - const mounted = await mountHook({ distanceToEnd: 2 }) - const before = mounted.geometry() - - mounted.resizeViewport(before.clientHeight) - - expect(mounted.geometry()).toEqual(before) - expect(mounted.scrollWrites).toEqual([]) + it("does not duplicate native growth above the fold", () => { + const h = mount() + h.move(500) + const top = bodyTop(h.root, "m5") + const offset = vi.spyOn(scrollFixture.latest.virtualizer, "scrollToOffset") + scrollFixture.bodyHeights.set("m0", 220) + resize() + expect(bodyTop(h.root, "m5")).toBeCloseTo(top, 0) + expect(offset).not.toHaveBeenCalled() }) - - it.each(VIEWPORT_RESIZE_BOUNDARIES)( - "resolves a composer growth from an initial %ipx tail distance", - async (distanceToEnd) => { - const { geometry, resizeViewport } = await mountHook({ distanceToEnd }) - - resizeViewport(799) - - const expectedScrollTop = distanceToEnd <= 100 - ? 1_600 - 799 - distanceToEnd - : 1_600 - 800 - distanceToEnd - expect(geometry()).toEqual({ - clientHeight: 799, - scrollHeight: 1_600, - scrollTop: expectedScrollTop, - }) - expect(virtualizer.scrollToEnd).not.toHaveBeenCalled() - }, - ) - - it.each(VIEWPORT_RESIZE_BOUNDARIES)( - "resolves a composer shrink from an initial %ipx tail distance", - async (distanceToEnd) => { - const { geometry, resizeViewport } = await mountHook({ distanceToEnd }) - - resizeViewport(801) - - const expectedScrollTop = distanceToEnd <= 100 - ? 1_600 - 801 - distanceToEnd - : 1_600 - 800 - distanceToEnd - expect(geometry()).toEqual({ - clientHeight: 801, - scrollHeight: 1_600, - scrollTop: expectedScrollTop, - }) - expect(virtualizer.scrollToEnd).not.toHaveBeenCalled() - }, - ) - - it("preserves tail distance regardless of scroll/resize callback ordering", async () => { - for (const order of ["scroll-ro", "ro-scroll"] as const) { - const mounted = await mountHook({ distanceToEnd: 2 }) - - mounted.resizeViewport(803, order) - - const { clientHeight, scrollHeight, scrollTop } = mounted.geometry() - expect(scrollHeight - clientHeight - scrollTop).toBe(2) - resetHarness() + it("settles same-anchor hero growth once after native layout", () => { + scrollFixture.bodyHeights.set("m0", 1200) + const h = mount() + h.move(400) + const top = bodyTop(h.root, "m0") + const offset = vi.spyOn(scrollFixture.latest.virtualizer, "scrollToOffset") + scrollFixture.firstPrefix += 40 + h.update({ items: [...h.input.items] }) + expect(bodyTop(h.root, "m0")).toBeCloseTo(top, 0) + expect(offset).toHaveBeenCalledTimes(1) + resize() + expect(offset).toHaveBeenCalledTimes(1) + }) + it.each([0, 1, 2])("keeps passive growth separate at %ipx from the end", distance => { + const h = mount() + h.move(h.root.scrollHeight - scrollFixture.height - distance) + const before = h.root.scrollTop + const offset = vi.spyOn(scrollFixture.latest.virtualizer, "scrollToOffset") + scrollFixture.bodyHeights.set("m13", 180) + resize() + expect(h.root.scrollTop - before).toBe(distance <= 1 ? 80 : 0) + expect(offset).not.toHaveBeenCalled() + }) + it("does not add a second prefix correction to a pinned single long row", () => { + scrollFixture.bodyHeights.set("m0", 1800) + const h = mount({ items: [message("m0")] }) + const before = h.root.scrollTop + const offset = vi.spyOn(scrollFixture.latest.virtualizer, "scrollToOffset") + scrollFixture.firstPrefix += 60 + h.update({ items: [...h.input.items] }) + expect(h.root.scrollTop - before).toBe(60) + expect(offset).not.toHaveBeenCalled() + }) + it.each([0, 99, 100, 101])("qualifies peer append from the actual prior %ipx tail distance", distance => { + const h = mount() + h.move(h.root.scrollHeight - scrollFixture.height - distance) + const end = vi.spyOn(scrollFixture.latest.virtualizer, "scrollToEnd") + h.update({ items: [...h.input.items, message("peer-new")] }) + expect(end).toHaveBeenCalledTimes(distance <= 100 ? 1 : 0) + }) + it("does not follow a historical self-authored tail from newer pagination", () => { + const h = mount({ viewerUserId: "me" }) + h.move(300) + const end = vi.spyOn(scrollFixture.latest.virtualizer, "scrollToEnd") + act(() => scrollFixture.latest.captureNewerPageAnchor()) + h.update({ isFetchingNewer: true }) + h.update({ items: [...h.input.items, message("historical-me", "me")], isFetchingNewer: false }) + h.update({ items: [...h.input.items] }) + expect(end).not.toHaveBeenCalled() + }) + it("does not revive a canceled Present when its tail arrives late", () => { + const h = mount({ hasMoreNewer: true, newDividerBefore: "m4", items: Array.from({ length: 14 }, (_, i) => ({ ...message(`m${i}`), newDivider: i === 4 })) }) + act(() => scrollFixture.latest.requestPresentPosition()) + h.move(300) + const end = vi.spyOn(scrollFixture.latest.virtualizer, "scrollToEnd") + h.update({ items: [...h.input.items, message("historical-me", "me")], hasMoreNewer: false, presentVersion: 1, viewerUserId: "me" }) + h.update({ items: [...h.input.items] }) + expect(end).not.toHaveBeenCalled() + }) + it("finishes only the active Present ticket when the current tail arrives", () => { + const h = mount({ hasMoreNewer: true, newDividerBefore: "m4", items: Array.from({ length: 14 }, (_, i) => ({ ...message(`m${i}`), newDivider: i === 4 })) }) + const end = vi.spyOn(scrollFixture.latest.virtualizer, "scrollToEnd") + act(() => scrollFixture.latest.requestPresentPosition()) + expect(scrollFixture.latest.readPositionReady).toBe(false) + h.update({ items: [...h.input.items, message("present-tail")], hasMoreNewer: false, presentVersion: 1 }) + expect(end).toHaveBeenCalledOnce() + expect(h.root.scrollTop).toBe(h.root.scrollHeight - scrollFixture.height) + expect(scrollFixture.latest.readPositionReady).toBe(true) + h.update({ items: [...h.input.items] }) + expect(end).toHaveBeenCalledOnce() + }) + it("waits for a positive viewport before proving the original semantic position", () => { + scrollFixture.height = 0 + const h = mount({ newDividerBefore: "m4", items: Array.from({ length: 14 }, (_, i) => ({ ...message(`m${i}`), newDivider: i === 4 })) }) + expect(scrollFixture.latest.readPositionReady).toBe(false) + scrollFixture.height = 500 + resize() + expect(scrollFixture.latest.readPositionReady).toBe(true) + act(() => scrollFixture.latest.jumpTo("m8", "auto")) + scrollFixture.height = 0 + resize() + expect(scrollFixture.latest.readPositionReady).toBe(false) + scrollFixture.height = 500 + resize() + expect(scrollFixture.latest.readPositionReady).toBe(true) + }) + it("does not settle prefix or footer candidates while a pointer or moving touch is active", () => { + scrollFixture.bodyHeights.set("m0", 1800) + const h = mount() + h.move(400) + const offset = vi.spyOn(scrollFixture.latest.virtualizer, "scrollToOffset") + fireEvent.pointerDown(h.root, { pointerId: 9 }) + scrollFixture.firstPrefix += 40 + h.update({ items: [...h.input.items] }) + expect(offset).not.toHaveBeenCalled() + fireEvent.pointerUp(document, { pointerId: 9 }) + runFrames() + fireEvent.touchStart(h.root, { touches: [{ clientY: 200 }] }) + fireEvent.touchMove(h.root, { touches: [{ clientY: 180 }] }) + scrollFixture.height -= 100 + resize() + expect(offset).not.toHaveBeenCalled() + fireEvent.blur(window) + runFrames() + expect(scrollFixture.latest.readPositionReady).toBe(true) + expect(offset).not.toHaveBeenCalled() + }) + it.each([0, 2, 100, 300])("retains the existing footer policy at %ipx and consumes duplicate delivery", distance => { + const h = mount() + h.move(h.root.scrollHeight - scrollFixture.height - distance) + const before = h.root.scrollTop + const offset = vi.spyOn(scrollFixture.latest.virtualizer, "scrollToOffset") + scrollFixture.height -= 100 + resize() + expect(h.root.scrollTop).toBe(before + (distance <= 100 ? 100 : 0)) + const calls = offset.mock.calls.length + resize() + expect(offset).toHaveBeenCalledTimes(calls) + }) + it.each([0, 2, 100, 300])("preserves the footer policy at %ipx when viewport growth changes the folded row", distance => { + const h = mount() + h.move(h.root.scrollHeight - scrollFixture.height - distance) + const before = h.root.scrollTop + scrollFixture.height += 200 + resize() + expect(h.root.scrollTop).toBe(before - (distance <= 100 ? 200 : 0)) + if (distance <= 100) expect(h.root.scrollHeight - scrollFixture.height - h.root.scrollTop).toBe(distance) + }) + it("preserves a current two-pixel tail distance when the footer grows before the scroll quiet period", () => { + const h = mount() + act(() => h.root.scrollTo({ top: h.root.scrollHeight - scrollFixture.height - 2 })) + runFrames(2) + scrollFixture.height += 200 + resize() + expect(h.root.scrollHeight - scrollFixture.height - h.root.scrollTop).toBe(2) + }) + it.each([0, 2, 100, 101])("preserves the footer policy at %ipx across native mobile/desktop tail padding", distance => { + const h = mount({ tailPaddingEnd: 40 }) + h.move(h.root.scrollHeight - scrollFixture.height - distance) + const before = h.root.scrollTop + const offset = vi.spyOn(scrollFixture.latest.virtualizer, "scrollToOffset") + h.update({ tailPaddingEnd: 48 }) + expect(h.root.scrollTop).toBe(before + (distance <= 100 ? 8 : 0)) + const calls = offset.mock.calls.length + h.update({ items: [...h.input.items] }) + expect(offset).toHaveBeenCalledTimes(calls) + if (distance === 0) { + scrollFixture.bodyHeights.set("m13", 240) + h.update({ items: [...h.input.items] }) + expect(h.root.scrollTop).toBe(h.root.scrollHeight - scrollFixture.height) + expect(offset).toHaveBeenCalledTimes(calls) } }) - - it("repairs browser shrink clamping in the footer-mutation microtask before ResizeObserver", async () => { - const mounted = await mountHook({ distanceToEnd: 100 }) - - mounted.setClientHeight(920) - expect(mounted.geometry().scrollHeight - mounted.geometry().clientHeight - mounted.geometry().scrollTop) - .toBe(0) - mounted.dispatchMutation() - - expect(mounted.geometry().scrollHeight - mounted.geometry().clientHeight - mounted.geometry().scrollTop) - .toBe(100) - mounted.resizeViewport(920) - expect(mounted.geometry().scrollHeight - mounted.geometry().clientHeight - mounted.geometry().scrollTop) - .toBe(100) + it("preserves a two-pixel DM tail distance through consecutive composer resizes", () => { + const h = mount() + scrollFixture.height -= 100 + resize(2) + act(() => h.root.scrollTo({ top: h.root.scrollHeight - scrollFixture.height - 2 })) + runFrames(2) + scrollFixture.height += 100 + resize() + expect(h.root.scrollHeight - h.root.clientHeight - h.root.scrollTop).toBe(2) }) - - it.each([ - { name: "grow→grow", heights: [780, 760] }, - { name: "shrink→shrink", heights: [801, 802] }, - { name: "grow→shrink", heights: [780, 800] }, - ])("preserves a 2px tail distance across rapid $name sequences", async ({ heights }) => { - const { geometry, resizeViewport } = await mountHook({ distanceToEnd: 2 }) - - for (const height of heights) resizeViewport(height) - - const { clientHeight, scrollHeight, scrollTop } = geometry() - expect(scrollHeight - clientHeight - scrollTop).toBe(2) + it.each([0, 2, 8, 100, 300])("preserves the %ipx footer policy in the first two resize frames", distance => { + const h = mount() + h.move(h.root.scrollHeight - scrollFixture.height - distance) + const before = h.root.scrollTop + scrollFixture.height += 200 + resize(2) + if (distance <= 100) expect(h.root.scrollHeight - h.root.clientHeight - h.root.scrollTop).toBe(distance) + else expect(h.root.scrollTop).toBe(before) }) - - it("preserves a 300px reading scrollTop across rapid growth and shrink", async () => { - const { geometry, resizeViewport } = await mountHook({ distanceToEnd: 300 }) - const initialScrollTop = geometry().scrollTop - - resizeViewport(780) - resizeViewport(760) - resizeViewport(800) - - expect(geometry().scrollTop).toBe(initialScrollTop) + it.each([[40, 48], [48, 40]])("retains an explicit present scroll after padding changes from %i to %i", (from, to) => { + const h = mount({ tailPaddingEnd: from }) + h.move(300) + h.stage({ tailPaddingEnd: to }) + act(() => h.root.scrollTo({ top: h.root.scrollHeight })) + resize() + expect(h.root.scrollHeight - h.root.clientHeight - h.root.scrollTop).toBe(0) + expect(scrollFixture.latest.belowCount).toBe(0) }) - - it("lets the existing explicit end action restore the latch", async () => { - const { dispatchScroll, geometry, resizeViewport, result } = await mountHook({ - distanceToEnd: 300, - }) - - result.scrollToBottom() - dispatchScroll() - virtualizer.scrollToEnd.mockClear() - resizeViewport(799) - - expect(geometry().scrollHeight - geometry().clientHeight - geometry().scrollTop).toBe(0) - expect(virtualizer.scrollToEnd).not.toHaveBeenCalled() + it.each(["wheel", "touch", "pointer"])("keeps footer clamp compensation behind active %s input", input => { + const h = mount() + h.move(h.root.scrollHeight - scrollFixture.height - 2) + const offset = vi.spyOn(scrollFixture.latest.virtualizer, "scrollToOffset") + if (input === "wheel") fireEvent.wheel(h.root, { deltaY: -2 }) + if (input === "touch") fireEvent.touchStart(h.root, { touches: [{ clientY: 200 }] }) + if (input === "pointer") fireEvent.pointerDown(h.root, { pointerId: 9 }) + scrollFixture.height += 100 + resize(2) + expect(offset).not.toHaveBeenCalled() }) - - it("restores the latch for warm-mount and peer-follow end actions", async () => { - const first = messageItem("m1", "peer") - const { geometry, rerender, resizeViewport } = await mountHook({ - distanceToEnd: 2, - items: [first], - heroMeasured: true, - }) - - expect(virtualizer.scrollToEnd).toHaveBeenCalledTimes(1) - expect(virtualizer.options.anchorTo).toBe("end") - - virtualizer.scrollToEnd.mockClear() - rerender({ - items: [first, messageItem("m2", "peer")], - viewerUserId: "viewer", + it("does not treat a resize clamp during ongoing native scrolling as a settled footer", () => { + const h = mount() + h.move(h.root.scrollHeight - scrollFixture.height - 8) + act(() => h.root.scrollTo({ top: h.root.scrollTop + 6 })) + runFrames(2) + expect(scrollFixture.latest.virtualizer.isScrolling).toBe(true) + const offset = vi.spyOn(scrollFixture.latest.virtualizer, "scrollToOffset") + scrollFixture.height += 100 + resize(2) + expect(offset).not.toHaveBeenCalled() + }) + it.each(["document", "footer"])("keeps a settled viewport anchor after an unregistered %s pointer release", target => { + const h = mount() + h.move(h.root.scrollHeight - scrollFixture.height - 2) + const footer = h.root.closest('[data-slot="community-conversation-surface"]')! + .querySelector('[data-slot="community-conversation-footer"]')! + const release = new Event("pointerup", { bubbles: true }) + Object.defineProperty(release, "pointerId", { value: 41 }) + fireEvent(target === "document" ? document : footer, release) + scrollFixture.height += 100 + resize(2) + expect(h.root.scrollHeight - h.root.clientHeight - h.root.scrollTop).toBe(2) + }) + it("keeps a real scroller pointer protected when released outside the scroller", () => { + const h = mount() + h.move(h.root.scrollHeight - scrollFixture.height - 2) + const offset = vi.spyOn(scrollFixture.latest.virtualizer, "scrollToOffset") + const down = new Event("pointerdown", { bubbles: true }) + Object.defineProperty(down, "pointerId", { value: 41 }) + fireEvent(h.root, down) + const up = new Event("pointerup", { bubbles: true }) + Object.defineProperty(up, "pointerId", { value: 41 }) + fireEvent(document, up) + scrollFixture.height += 100 + resize(2) + expect(offset).not.toHaveBeenCalled() + }) + it("does not release an active scroller pointer when another pointer ends outside", () => { + const h = mount() + h.move(h.root.scrollHeight - scrollFixture.height - 2) + const offset = vi.spyOn(scrollFixture.latest.virtualizer, "scrollToOffset") + const down = new Event("pointerdown", { bubbles: true }) + Object.defineProperty(down, "pointerId", { value: 41 }) + fireEvent(h.root, down) + const up = new Event("pointerup", { bubbles: true }) + Object.defineProperty(up, "pointerId", { value: 42 }) + fireEvent(document, up) + runFrames(13) + scrollFixture.height += 100 + resize(2) + expect(offset).not.toHaveBeenCalled() + }) + it.each([0, 1])("preserves a settled viewport tail distance after ResizeObserver and %i later RAFs", frames => { + const h = mount() + h.move(h.root.scrollHeight - scrollFixture.height - 2) + scrollFixture.height += 100 + act(() => { + h.root.scrollTop = h.root.scrollHeight - h.root.clientHeight + h.root.dispatchEvent(new Event("scroll")) }) - expect(virtualizer.scrollToEnd).toHaveBeenCalledTimes(1) - expect(virtualizer.options.anchorTo).toBe("end") - - virtualizer.scrollToEnd.mockClear() - resizeViewport(799) - expect(geometry().scrollHeight - geometry().clientHeight - geometry().scrollTop).toBe(0) - expect(virtualizer.scrollToEnd).not.toHaveBeenCalled() + resize(0) + runFrames(frames) + expect(h.root.scrollHeight - h.root.clientHeight - h.root.scrollTop).toBe(2) }) - - it("restores the latch for an explicit present action", async () => { - const { geometry, resizeViewport } = await mountHook({ - distanceToEnd: 300, - items: [messageItem("m1")], - presentVersion: 1, + it.each([0, 2, 8, 100, 300])("preserves the %ipx footer policy in the first RAF before native ResizeObserver", distance => { + const h = mount() + h.move(h.root.scrollHeight - scrollFixture.height - distance) + const before = h.root.scrollTop + scrollFixture.height += 200 + runFrames(1) + expect(h.root.scrollTop).toBe(before - (distance <= 100 ? 200 : 0)) + resize(0) + expect(h.root.scrollTop).toBe(before - (distance <= 100 ? 200 : 0)) + }) + it("retains a settled viewport owner through successive first-RAF composer clamps", () => { + const h = mount() + h.move(h.root.scrollHeight - scrollFixture.height - 2) + for (let i = 0; i < 3; i++) { + scrollFixture.height += 50 + runFrames(1) + expect(h.root.scrollHeight - h.root.clientHeight - h.root.scrollTop).toBe(2) + resize(0) + expect(h.root.scrollHeight - h.root.clientHeight - h.root.scrollTop).toBe(2) + runFrames(1) + expect(h.root.scrollHeight - h.root.clientHeight - h.root.scrollTop).toBe(2) + } + }) + it("keeps a registered pointer cancellation protected outside the scroller", () => { + const h = mount() + h.move(h.root.scrollHeight - scrollFixture.height - 2) + const offset = vi.spyOn(scrollFixture.latest.virtualizer, "scrollToOffset") + const down = new Event("pointerdown", { bubbles: true }) + Object.defineProperty(down, "pointerId", { value: 41 }) + fireEvent(h.root, down) + const cancel = new Event("pointercancel", { bubbles: true }) + Object.defineProperty(cancel, "pointerId", { value: 41 }) + fireEvent(document, cancel) + scrollFixture.height += 100 + resize(2) + expect(offset).not.toHaveBeenCalled() + }) + it("retires a held scroller pointer after window blur and the input quiet window", () => { + const h = mount() + h.move(h.root.scrollHeight - scrollFixture.height - 2) + const down = new Event("pointerdown", { bubbles: true }) + Object.defineProperty(down, "pointerId", { value: 41 }) + fireEvent(h.root, down) + fireEvent(window, new Event("blur")) + runFrames(13) + scrollFixture.height += 100 + resize(2) + expect(h.root.scrollHeight - h.root.clientHeight - h.root.scrollTop).toBe(2) + }) + it("rejects an unrelated native rectangle during a first-RAF viewport handoff", () => { + const h = mount() + h.move(h.root.scrollHeight - scrollFixture.height - 2) + const offset = vi.spyOn(scrollFixture.latest.virtualizer, "scrollToOffset") + scrollFixture.latest.virtualizer.scrollRect = { width: 320, height: 300 } + scrollFixture.height += 100 + runFrames(1) + expect(offset).not.toHaveBeenCalled() + }) + it.each([0, 2, 8, 100, 300])("preserves the %ipx footer policy before native RO when composer growth does not clamp", async distance => { + const h = mount() + h.move(h.root.scrollHeight - scrollFixture.height - distance) + const before = h.root.scrollTop + const footer = h.root.closest('[data-slot="community-conversation-surface"]')! + .querySelector('[data-slot="community-conversation-footer"]')! + await act(async () => { + scrollFixture.height -= 100 + footer.textContent = "growing draft" }) - - expect(virtualizer.scrollToEnd).toHaveBeenCalledTimes(1) - expect(virtualizer.options.anchorTo).toBe("end") - - virtualizer.scrollToEnd.mockClear() - resizeViewport(799) - expect(geometry().scrollHeight - geometry().clientHeight - geometry().scrollTop).toBe(0) - expect(virtualizer.scrollToEnd).not.toHaveBeenCalled() + runFrames(1) + expect(h.root.scrollTop).toBe(before + (distance <= 100 ? 100 : 0)) + resize(0) + expect(h.root.scrollTop).toBe(before + (distance <= 100 ? 100 : 0)) }) - - it("re-pins an exactly pinned image load but ignores one after upward intent", async () => { - const { listeners, result } = await mountHook({ distanceToEnd: 0 }) - - virtualizer.options.anchorTo = "start" - result.onImageLoad() - expect(virtualizer.options.anchorTo).toBe("end") - expect(virtualizer.scrollToEnd).toHaveBeenCalledTimes(1) - - virtualizer.scrollToEnd.mockClear() - listeners.get("wheel")?.({ deltaY: -1 } as WheelEvent) - result.onImageLoad() - expect(virtualizer.options.anchorTo).toBe("start") - expect(virtualizer.scrollToEnd).not.toHaveBeenCalled() + it("fills a true single short message through native padding and crosses short/long normally", () => { + const h = mount({ items: [message("m0")] }) + expect(h.root.scrollTop).toBe(0) + expect(scrollFixture.latest.virtualizer.getTotalSize()).toBe(scrollFixture.height) + h.update({ items: Array.from({ length: 14 }, (_, i) => message(`m${i}`)) }) + expect(scrollFixture.latest.virtualizer.getTotalSize()).toBeGreaterThan(scrollFixture.height) + h.update({ items: [message("m0")] }) + expect(scrollFixture.latest.virtualizer.getTotalSize()).toBe(scrollFixture.height) + expect(scrollFixture.latest.virtualizer.options.paddingStart).toBeGreaterThan(0) }) - - it("uses exact-pinned rather than 100px near-bottom for delayed row growth", async () => { - const { scrollWrites } = await mountHook({ distanceToEnd: 99 }) - const row = growingRow(() => {}) - const measure = virtualizerOptions?.measureElement - - expect(measure!(row.element, undefined, virtualizer as never)).toBe(400) - row.growTo(500) - expect(measure!(row.element, undefined, virtualizer as never)).toBe(500) - await Promise.resolve() - - expect(scrollWrites).toEqual([]) + it("keeps a genuinely empty list free of message keys and positions the first actual message", () => { + const h = mount({ items: [] }) + expect(scrollFixture.latest.virtualizer.getVirtualItems()).toEqual([]) + expect(h.root.querySelector('[data-msg-id]')).toBeNull() + h.update({ items: [message("first")] }) + expect(scrollFixture.latest.readPositionReady).toBe(true) + expect(scrollFixture.latest.virtualizer.getVirtualItems().map(item => item.key)).toEqual(["msg:first"]) + expect(h.root.scrollTop).toBe(0) + expect(scrollFixture.latest.virtualizer.getTotalSize()).toBe(scrollFixture.height) + }) + it("stops idle geometry frames when the last message is removed", () => { + const h = mount() + h.update({ items: [] }) + runFrames() + const frames = vi.mocked(window.requestAnimationFrame) + frames.mockClear() + runFrames(5) + expect(frames).not.toHaveBeenCalled() + }) + it("follows a new self-send once and retains its stable display key on canonical acknowledgement", () => { + const h = mount({ viewerUserId: "me" }) + h.move(300) + const end = vi.spyOn(scrollFixture.latest.virtualizer, "scrollToEnd") + const optimistic = { ...message("optimistic", "me"), key: "client:send-1" } + h.update({ items: [...h.input.items, optimistic] }) + expect(end).toHaveBeenCalledOnce() + const canonical = { ...optimistic, m: { ...optimistic.m, id: "canonical" } } + h.update({ items: [...h.input.items.slice(0, -1), canonical] }) + expect(end).toHaveBeenCalledOnce() + expect(scrollFixture.latest.virtualizer.getVirtualItems().at(-1)?.key).toBe("client:send-1") + }) + it("keeps read closed at the early warm tail until the original unread boundary is proven", () => { + const h = mount({ initialScrollReady: false }) + expect(scrollFixture.latest.readPositionReady).toBe(false) + expect(h.root.scrollTop).toBe(h.root.scrollHeight - scrollFixture.height) + h.update({ initialScrollReady: true, newDividerBefore: "m4", items: h.input.items.map(item => ({ ...item, newDivider: item.m.id === "m4" })) }) + const marker = h.root.querySelector('[data-new-divider]')!.getBoundingClientRect() + expect((marker.top + marker.bottom) / 2).toBeCloseTo(scrollFixture.height / 2, 0) + expect(scrollFixture.latest.readPositionReady).toBe(true) + }) + it("combines current prefix growth with the existing footer reading-offset policy once", () => { + scrollFixture.bodyHeights.set("m0", 1800) + const h = mount() + h.move(400) + const before = bodyTop(h.root, "m0") + const offset = vi.spyOn(scrollFixture.latest.virtualizer, "scrollToOffset") + scrollFixture.height -= 100 + scrollFixture.firstPrefix += 40 + h.update({ items: [...h.input.items] }) + expect(bodyTop(h.root, "m0")).toBeCloseTo(before, 0) + expect(offset).toHaveBeenCalledOnce() + resize() + expect(offset).toHaveBeenCalledOnce() }) }) diff --git a/src/web/src/hooks/community/use-scroll-anchor.test.ts b/src/web/src/hooks/community/use-scroll-anchor.test.ts index fa92ed44a..40545f78d 100644 --- a/src/web/src/hooks/community/use-scroll-anchor.test.ts +++ b/src/web/src/hooks/community/use-scroll-anchor.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect } from "vitest" import { decideScrollAction, createScrollAnchorState, - computeHeroScrollCompensation, findMessageIndex, findMountScrollTargetIndex, extractScrollAnchorMessages, @@ -11,12 +10,10 @@ import { NEAR_BOTTOM_PX, resolveMessageRailTailPaddingEnd, resolveViewportResizeAnchor, - shouldAdjustMessageScrollPosition, type ScrollAnchorState, type ScrollAnchorMessage, } from "./use-scroll-anchor" import type { FlatItem } from "@/lib/community/message-list-items" -import type { VirtualItem } from "@tanstack/react-virtual" const msgs = (...ids: string[]): ScrollAnchorMessage[] => ids.map((id) => ({ id })) @@ -78,7 +75,7 @@ function baseInput(overrides: Partial[0]> state: createScrollAnchorState(), messages: msgs("m1", "m2", "m3"), initialScrollReady: true, - heroMeasured: true, + viewportReady: true, isAtEnd: true, ...overrides, } @@ -118,7 +115,7 @@ describe("decideScrollAction — mount (rewritten — neither case is free with expect(nextState.didInitialScroll).toBe(false) }) - it("does not fire (and does not consume the one-shot gate) until the hero has been measured at least once", () => { + it("does not fire (and does not consume the one-shot gate) until the actual viewport is ready", () => { // Regression: the hero's real height is only known after its own // ResizeObserver effect runs (a later commit than the one that first // renders real messages). scrollMargin defaults to 0 until then — if @@ -129,19 +126,19 @@ describe("decideScrollAction — mount (rewritten — neither case is free with // as: page loads, view flashes at the tail then snaps to the top hero, // and unread messages near the true tail never enter the viewport so // useChannelWatermark never advances the read pointer. - const { action, nextState } = decideScrollAction(baseInput({ heroMeasured: false })) + const { action, nextState } = decideScrollAction(baseInput({ viewportReady: false })) expect(action).toEqual({ type: "none" }) expect(nextState.didInitialScroll).toBe(false) }) - it("for a cold / mid-history window fires the full mount once both initialScrollReady and heroMeasured become true, even if one lagged", () => { + it("for a cold / mid-history window fires the full mount once both initialScrollReady and viewportReady become true, even if one lagged", () => { // hasMoreNewer keeps it off the early-bottom path so this still exercises // the "wait for both gates, then mount" sequence. - const notReady = decideScrollAction(baseInput({ initialScrollReady: false, heroMeasured: false, hasMoreNewer: true })) + const notReady = decideScrollAction(baseInput({ initialScrollReady: false, viewportReady: false, hasMoreNewer: true })) expect(notReady.action).toEqual({ type: "none" }) - const heroOnly = decideScrollAction(baseInput({ state: notReady.nextState, initialScrollReady: false, heroMeasured: true, hasMoreNewer: true })) + const heroOnly = decideScrollAction(baseInput({ state: notReady.nextState, initialScrollReady: false, viewportReady: true, hasMoreNewer: true })) expect(heroOnly.action).toEqual({ type: "none" }) - const bothReady = decideScrollAction(baseInput({ state: heroOnly.nextState, initialScrollReady: true, heroMeasured: true, hasMoreNewer: true })) + const bothReady = decideScrollAction(baseInput({ state: heroOnly.nextState, initialScrollReady: true, viewportReady: true, hasMoreNewer: true })) expect(bothReady.action.type).toBe("mount") // Full mount with the snapshot already resolved completes both phases. expect(bothReady.nextState.didDividerConverge).toBe(true) @@ -353,102 +350,14 @@ describe("measureMessageRow", () => { }) }) -describe("shouldAdjustMessageScrollPosition", () => { - const item: VirtualItem = { - key: "message:12", - index: 12, - start: 800, - end: 900, - size: 100, - lane: 0, - } - - function instance(overrides: Partial[2]> = {}) { - return { - itemSizeCache: new Map(), - scrollAdjustments: 0, - scrollDirection: null, - scrollOffset: 1_000, - ...overrides, - } - } - - it("does not compensate a first measurement while the user scrolls upward", () => { - expect(shouldAdjustMessageScrollPosition(item, 96, instance({ scrollDirection: "backward" }))).toBe(false) - }) - - it("compensates a measured row wholly above an away viewport", () => { - const measured = new Map([[item.key, item.size]]) - expect(shouldAdjustMessageScrollPosition( - item, - 240, - instance({ itemSizeCache: measured }), - true, - )).toBe(true) - }) - - it("does not compensate a measured row intersecting an away viewport", () => { - const measured = new Map([[item.key, item.size]]) - expect(shouldAdjustMessageScrollPosition( - { ...item, end: 1_040 }, - 240, - instance({ itemSizeCache: measured }), - true, - )).toBe(false) - }) - - it("does not compensate an estimate-to-measure pass after the viewer leaves the bottom", () => { - expect(shouldAdjustMessageScrollPosition(item, 96, instance(), true)).toBe(false) - }) - - it("keeps first-measurement compensation above the fold while idle or scrolling forward", () => { - expect(shouldAdjustMessageScrollPosition(item, 96, instance())).toBe(true) - expect(shouldAdjustMessageScrollPosition(item, 96, instance({ scrollDirection: "forward" }))).toBe(true) - }) - - it("keeps remeasurement compensation only for rows entirely above the fold", () => { - const measured = new Map([[item.key, item.size]]) - expect(shouldAdjustMessageScrollPosition(item, 20, instance({ itemSizeCache: measured }))).toBe(true) - expect(shouldAdjustMessageScrollPosition( - { ...item, end: 1_040 }, - 20, - instance({ itemSizeCache: measured }), - )).toBe(false) - }) - - it("includes pending virtualizer adjustments in the effective fold offset", () => { - expect(shouldAdjustMessageScrollPosition( - { ...item, start: 1_020, end: 1_120 }, - 20, - instance({ scrollAdjustments: 40 }), - )).toBe(true) - }) -}) - -describe("computeHeroScrollCompensation", () => { - it("returns 0 when the hero's height is unchanged", () => { - expect(computeHeroScrollCompensation(80, 80)).toBe(0) - }) - - it("returns a positive delta when the hero grows (e.g. sentinel swaps for the full 'Beginning of channel' block)", () => { - expect(computeHeroScrollCompensation(0, 96)).toBe(96) - }) - - it("returns a negative delta when the hero shrinks", () => { - expect(computeHeroScrollCompensation(96, 40)).toBe(-56) - }) -}) - describe("findMountScrollTargetIndex", () => { const items: FlatItem[] = [ - { kind: "date-divider", label: "Today", key: "d1" }, { kind: "message", m: { id: "m1", type: "chat", grouped: false }, key: "msg:m1" }, - { kind: "new-divider", key: "new-divider" }, { kind: "message", m: { id: "m2", type: "chat", grouped: false }, key: "msg:m2" }, ] - it("prefers the new-divider ROW itself over the message row it precedes — it's now its own flattened item, thin, not the whole message box", () => { - expect(findMountScrollTargetIndex(items, "m2")).toBe(2) + it("finds the message owning New so native can mount its actual prefix", () => { + expect(findMountScrollTargetIndex(items, "m2")).toBe(1) }) it("falls back to the message's own index when no new-divider item exists (e.g. first-visit anchoring on a non-self message with no divider rendered)", () => { @@ -466,13 +375,12 @@ describe("findMountScrollTargetIndex", () => { describe("findMessageIndex", () => { const items: FlatItem[] = [ - { kind: "date-divider", label: "Today", key: "d1" }, { kind: "message", m: { id: "m1", type: "chat", grouped: false }, key: "msg:m1" }, { kind: "message", m: { id: "m2", type: "chat", grouped: true }, key: "msg:m2" }, ] it("returns the item-array index of the message with the given id", () => { - expect(findMessageIndex(items, "m2")).toBe(2) + expect(findMessageIndex(items, "m2")).toBe(1) }) it("returns null when the id isn't present (message not loaded)", () => { @@ -487,10 +395,8 @@ describe("findMessageIndex", () => { describe("extractScrollAnchorMessages", () => { it("extracts only 'message' items' id/authorId, in order, skipping dividers", () => { const items: FlatItem[] = [ - { kind: "date-divider", label: "Today", key: "d1" }, - { kind: "message", m: { id: "m1", type: "chat", grouped: false, authorId: "u1" }, key: "msg:m1" }, - { kind: "new-divider", key: "new-divider" }, - { kind: "message", m: { id: "m2", type: "chat", grouped: false }, key: "msg:m2" }, + { kind: "message", m: { id: "m1", type: "chat", grouped: false, authorId: "u1" }, key: "msg:m1" }, + { kind: "message", m: { id: "m2", type: "chat", grouped: false }, key: "msg:m2" }, ] expect(extractScrollAnchorMessages(items)).toEqual([ { id: "m1", authorId: "u1" }, @@ -499,7 +405,7 @@ describe("extractScrollAnchorMessages", () => { }) it("returns an empty array when there are no message items", () => { - const items: FlatItem[] = [{ kind: "date-divider", label: "Today", key: "d1" }] + const items: FlatItem[] = [] expect(extractScrollAnchorMessages(items)).toEqual([]) }) }) diff --git a/src/web/src/hooks/community/use-scroll-anchor.ts b/src/web/src/hooks/community/use-scroll-anchor.ts index ddd4b211a..ba7f09f72 100644 --- a/src/web/src/hooks/community/use-scroll-anchor.ts +++ b/src/web/src/hooks/community/use-scroll-anchor.ts @@ -1,49 +1,11 @@ import { useAtom, useCreateAtom } from "@tanstack/react-store"; -import { useCallback, useLayoutEffect, useRef } from "react" -import { useVirtualizer, type ReactVirtualizer, type VirtualItem } from "@tanstack/react-virtual" +import { useCallback, useLayoutEffect, useMemo, useRef } from "react" +import { useVirtualizer, type ReactVirtualizer } from "@tanstack/react-virtual" import { COMMUNITY_VIRTUALIZER_REACT_OPTIONS } from "./virtualizer-react-options" import { estimateRowHeight, computeBelowCount, type FlatItem } from "@/lib/community/message-list-items" -// Virtualized rewrite of message-list's scroll-anchoring logic. The -// pre-virtualization version (see git history) hand-rolled 4 branches — -// mount / self-send-peer-follow / older-prepend-compensation / hero-swap- -// compensation — verified against `record-debt-community-messages.md` -// findings #2/#10 (two disagreeing "near bottom" thresholds, a same-commit -// double-write race, a silently-unhandled compound case). -// -// `@tanstack/react-virtual`'s `anchorTo: "end"` handles ordinary edge-key -// changes, but a same-day prepend retains the leading date-divider key. At the -// top edge the library then anchors that structural row instead of the first -// visible message. This hook captures the real message before `fetchOlder` -// and restores its id + viewport offset after the page lands. -// `decideScrollAction` below covers the other 2 behaviors the library does -// NOT provide: -// mount-time positioning and self-send/peer-follow. Hero-swap compensation -// is also NOT delegated to the library (`scrollMargin` shifts the -// measurement coordinate system only — verified it never triggers a -// `scrollOffset` write) and is handled by the separate, narrower -// `computeHeroScrollCompensation` below plus a dedicated `ResizeObserver` -// on the hero wrapper (see `useScrollAnchor`'s hero-tracking effect) — NOT -// the old catch-all `watchAsyncGrowth`, which is deleted along with its -// row-level image-decode case (native `resizeItem`/`applyScrollAdjustment` -// in the installed library replaces that one). -// -// `followOnAppend` is deliberately left OFF in the virtualizer config (see -// `useScrollAnchor`) — turning it on would let the library ALSO call -// `scrollToEnd()` on any append where `isAtEnd()` was already true, racing -// with this hook's own explicit self-send/peer-follow `scrollToEnd()` call -// in the same commit (same class of same-commit double-write the debt -// record already flagged once). `isAtEnd()` is still reused here as a cheap -// boolean read — only the ACT of scrolling stays single-sourced. - -// Shared "near bottom" threshold. Also passed as the virtualizer's own -// `scrollEndThreshold` config value (see `useScrollAnchor`) — NOT purely -// cosmetic: `scrollEndThreshold` independently gates the library's native -// `resizeItem` above-viewport compensation (defaults to 1px otherwise). +export const INITIAL_POSITION_TIMEOUT_MS = 2_000 export const NEAR_BOTTOM_PX = 100 - -// Reserve exactly the 32px pill plus its responsive bottom offset. The owner -// intentionally does not want additional visual clearance below messages. export const MESSAGE_RAIL_TAIL_PADDING_END_PX = { mobile: 40, desktop: 48, @@ -73,16 +35,6 @@ export interface ViewportResizeAnchorResult { distanceToEnd: number } -/** - * Resolve a conversation viewport resize from the last accepted geometry. - * - * Composer growth/shrink is a real normal-flow resize. Viewers in tail - * context retain their exact distance from the end, while readers farther - * away retain their top-based position. When the viewport grows by more than - * the old tail distance, a top anchor is physically impossible because the - * browser must clamp to the new maximum; that case deliberately falls back - * to the equivalent tail-distance anchor. - */ export function resolveViewportResizeAnchor({ previousClientHeight, nextClientHeight, @@ -113,84 +65,17 @@ export function resolveViewportResizeAnchor({ } } -/** - * Measure the row's current visual footprint instead of trusting the initial - * text estimate or a previously cached measurement. The virtualizer's default - * synchronous path deliberately reuses its cache, which can leave a narrow, - * long Markdown row at the 400px estimate when its content finishes laying - * out later. WebKit can also expose `borderBoxSize` differently across - * versions, so reading the live element here keeps the result independent of - * the ResizeObserverEntry shape. - * - * `scrollHeight` is included because a descendant that temporarily overflows - * the auto-sized wrapper still occupies visible space. Rounding up prevents a - * fractional CSS-pixel remainder from placing the following absolute row on - * top of the final text line. - */ export function measureMessageRow(element: Element): number { const row = element as HTMLElement return Math.ceil(Math.max(element.getBoundingClientRect().height, row.scrollHeight)) } - -// Structural fields used by `shouldAdjustMessageScrollPosition`. Do not -// `Pick<>` them from Virtualizer — `scrollAdjustments` / `itemSizeCache` are -// private on current @tanstack/virtual-core and break `tsc`. -type SizeAdjustmentVirtualizer = { - itemSizeCache: { has: (key: VirtualItem["key"]) => boolean } - scrollAdjustments: number - scrollDirection: "forward" | "backward" | null - scrollOffset: number | null -} - -/** - * Keep TanStack Virtual's normal estimate-to-measurement anchoring except - * while the user is actively scrolling upward. Its default first-measure - * branch compensates every row whose estimated top is above the fold, - * including rows that have just entered the overscan window during a - * backward scroll. With variable-height messages that correction pushes - * scrollTop downward by the estimate delta and repeatedly cancels wheel - * input — visible as the NEW-divider view shuddering and refusing to move. - * - * Re-measurements retain the library's narrower "entirely above the fold" - * rule even after the viewer leaves the bottom. That is not a bottom re-pin: - * it offsets growth above the viewport so the visible reading anchor stays - * put. Forward/idle first measurements retain the original rule, so image - * growth, prepend anchoring, and normal downward navigation keep their - * existing compensation behavior. - */ -export function shouldAdjustMessageScrollPosition( - item: VirtualItem, - _delta: number, - instance: SizeAdjustmentVirtualizer, - userScrolledAway = false, -): boolean { - const offset = (instance.scrollOffset ?? 0) + instance.scrollAdjustments - const isFirstMeasure = !instance.itemSizeCache.has(item.key) - if (isFirstMeasure && (userScrolledAway || instance.scrollDirection === "backward")) return false - return isFirstMeasure ? item.start < offset : item.end <= offset -} - export interface ScrollAnchorMessage { id: string authorId?: string } -interface PaginationAnchor { - direction: "older" | "newer" - messageId: string | null - viewportOffset: number | null - scrollTop: number -} - export interface ScrollAnchorState { didInitialScroll: boolean - // Instant channel switch: the mount scroll now has two phases. Phase 1 - // (`didInitialScroll`) puts the viewport at the bottom — it may fire early, - // off a warm tail-attached cache, before the read snapshot resolves. Phase 2 - // (`didDividerConverge`) repositions once to the NEW-divider when the read - // snapshot lands. When phase 1 already had the divider (read snapshot ready - // at mount) both complete together. `didDividerConverge` guards phase 2 so - // it fires at most once and never re-yanks after the user starts scrolling. didDividerConverge: boolean lastTailId: string | null } @@ -208,23 +93,10 @@ export interface DecideScrollActionInput { messages: ScrollAnchorMessage[] newDividerBefore?: string initialScrollReady: boolean - // Whether the hero block's real height has been measured at least once - // (the caller's `ResizeObserver` effect has fired). Gates mount the same - // way `initialScrollReady` does: firing mount while this is still false - // means `scrollMargin` is still its default 0, so `scrollToIndex`'s - // offset math (align: "center", for the NEW-divider case) targets the - // wrong scrollTop — and the virtualizer's own scroll-reconcile loop - // stabilizes on that wrong value within a frame, well before the hero's - // real height (a separate React state update) lands. Observed as: page - // loads, view flashes at the tail then snaps to the top hero, and unread - // messages near the true tail never enter the viewport so - // useChannelWatermark never advances the read pointer. - heroMeasured: boolean + viewportReady: boolean hasMoreNewer?: boolean isPaginatingNewer?: boolean viewerUserId?: string - // Whether the viewport was within NEAR_BOTTOM_PX of the end BEFORE this - // commit's append — the caller reads this off `virtualizer.isAtEnd(NEAR_BOTTOM_PX)`. isAtEnd: boolean userScrolledAway?: boolean } @@ -239,22 +111,8 @@ export interface DecideScrollActionResult { nextState: ScrollAnchorState } -/** - * Pure decision function — given the previous anchor state and this - * commit's inputs, decides AT MOST ONE scroll action, in priority order: - * 1. Mount-time initial scroll (fires exactly once — covers BOTH the - * divider-center case and the plain "start at the end" case; neither - * is free with `anchorTo: "end"`, which only engages its - * anchor-preserving logic on options DIFFS, not the constructor's - * initial `setOptions` call). - * 2. Self-send / peer-follow snap to end. - * Older-prepend compensation and hero-swap compensation are NOT decided - * here — see this file's module doc comment for where they moved. No DOM - * access — the caller (the hook) executes the chosen action against the - * real virtualizer. Exported for unit testing without DOM/hooks. - */ export function decideScrollAction(input: DecideScrollActionInput): DecideScrollActionResult { - const { state, messages, newDividerBefore, initialScrollReady, heroMeasured, hasMoreNewer, isPaginatingNewer, viewerUserId, isAtEnd, userScrolledAway } = input + const { state, messages, newDividerBefore, initialScrollReady, viewportReady, hasMoreNewer, isPaginatingNewer, viewerUserId, isAtEnd, userScrolledAway } = input const nextTail = messages[messages.length - 1]?.id ?? null const nextLen = messages.length @@ -264,67 +122,27 @@ export function decideScrollAction(input: DecideScrollActionInput): DecideScroll didDividerConverge: state.didDividerConverge, lastTailId: nextTail, } - - // Channel/DM cleared (or genuinely empty) — nothing to anchor, and RE-ARM - // the mount one-shot gate. The list can transiently empty AFTER a - // successful initial scroll when a live path invalidates the message query - // mid-mount — the observed trigger is `useCommunityWs`'s `handleReconnect`, - // which fires ~1.5s into a fresh load (a StrictMode / refresh double- - // connect makes the socket's `onReconnect` run once even on first paint) - // and invalidates BOTH `channelMessages` and the `gcTime: 0` - // `channelReadStateSnapshot`. That round-trips `messages` through `[]` - // (itemCount 48 → 0 → 48, verified via live Playwright trace). Without - // re-arming, the one-shot `didInitialScroll` gate stays consumed, so when - // the rows return the mount scroll never re-fires and the view is left - // parked at the top hero with the NEW divider off-screen — exactly the - // "content → skeleton → content → stuck at hero" refresh bug. Re-arming - // makes the next non-empty commit re-run the mount positioning. - // - // Safe for a genuine channel switch too: that path already gets a fresh - // hook instance (keyed by channelId/dmId — see this hook's doc comment), - // so this branch only ever matters for a same-scope transient empty. if (nextLen === 0) { return { action: { type: "none" }, nextState: { ...baseNextState, didInitialScroll: false, didDividerConverge: false }, } } - - // Whether the loaded window is tail-attached to the present (its newest page - // reaches "now"). Only then is scrolling "to the bottom" scrolling to the - // real latest message — an older-only / mid-history window (e.g. a jump - // target, or a cold anchor fetch) has `hasMoreNewer` true, and its bottom is - // a mid-history edge, so it must NOT take the early-bottom path (Cecilia's - // red line #1: judge the tail by hasMoreNewer, not "are there rows"). const tailAttached = !hasMoreNewer - - // Phase 1 — mount-time scroll to the bottom. Fires exactly once. Two ways in: - // • Read snapshot already resolved (`initialScrollReady`): do the full - // mount — divider target if there's an unread anchor, else the end — and - // complete BOTH phases at once (nothing left to converge). - // • Warm tail-attached cache, snapshot NOT yet resolved: paint-and-scroll - // to the end immediately so a revisit lands at the bottom on the first - // frame (instant switch) instead of waiting on the network. The - // NEW-divider then converges in phase 2 when the snapshot lands. - // Still bails until `heroMeasured` in both cases — firing on a default-0 - // scrollMargin mis-targets the scroll (see `heroMeasured`'s doc comment). if (!state.didInitialScroll) { - if (heroMeasured && userScrolledAway) { + if (viewportReady && userScrolledAway) { return { action: { type: "none" }, nextState: { ...baseNextState, didInitialScroll: true, didDividerConverge: true }, } } - if (heroMeasured && initialScrollReady) { + if (viewportReady && initialScrollReady) { return { action: { type: "mount", newDividerBefore }, nextState: { ...baseNextState, didInitialScroll: true, didDividerConverge: true }, } } - if (heroMeasured && tailAttached) { - // Early bottom: snapshot not ready, but a tail-attached warm cache means - // "bottom" is the true latest — scroll there now, leave phase 2 to place - // the divider once the snapshot resolves. + if (viewportReady && tailAttached) { return { action: { type: "scrollToEnd" }, nextState: { ...baseNextState, didInitialScroll: true, didDividerConverge: false }, @@ -335,21 +153,9 @@ export function decideScrollAction(input: DecideScrollActionInput): DecideScroll nextState: { ...baseNextState, didInitialScroll: false }, } } - - // Self-send / peer-follow — only relevant when the tail actually moved. const tailChanged = state.lastTailId !== null && state.lastTailId !== nextTail - - // Phase 2 — converge onto the NEW divider, exactly once, after an early - // bottom scroll. This is a MOUNT-SETTLING step, so it only applies while the - // tail is unchanged: once a live append arrives (`tailChanged`), we're past - // mount and the self-send/peer-follow logic below owns the viewport — a live - // append also consumes the convergence one-shot (we're no longer settling a - // fresh mount). Waits for the read snapshot (`initialScrollReady`) to name - // the anchor, and only repositions while the viewer is still parked at the - // bottom (`isAtEnd`): if they've started scrolling we must not yank them - // (Cecilia's red line #2 — converge, never pull a settled viewport back). if (!state.didDividerConverge && !tailChanged) { - if (!initialScrollReady || !heroMeasured) { + if (!initialScrollReady || !viewportReady) { return { action: { type: "none" }, nextState: baseNextState } } if (newDividerBefore && isAtEnd && !userScrolledAway) { @@ -358,36 +164,18 @@ export function decideScrollAction(input: DecideScrollActionInput): DecideScroll nextState: { ...baseNextState, didDividerConverge: true }, } } - // No divider to place, or the viewer already scrolled away — consume the - // one-shot without moving them. return { action: { type: "none" }, nextState: { ...baseNextState, didDividerConverge: true } } } if (tailChanged) { - // A live append means we're past mount-settling: the divider-convergence - // one-shot is spent (any pending convergence would now be a stale yank), so - // consume it on every tail-changed outcome below. const liveState = { ...baseNextState, didDividerConverge: true } - // A newer-page fetch also changes the tail id, but it is historical - // pagination rather than a live append. Its captured visible-message - // anchor owns the viewport; treating it as peer-follow would snap to the - // newly loaded page end and can virtualize the captured row away before - // reconciliation gets a chance to restore it. if (isPaginatingNewer) { return { action: { type: "none" }, nextState: liveState } } const tail = messages[messages.length - 1] const isSelfSend = !!viewerUserId && tail?.authorId === viewerUserId if (isSelfSend) { - // Always follow — handles the composer path and the overlay identity - // advance on postAck (temp id → canonical server id). The author remains - // the viewer, so this branch treats that id change as an idempotent - // self-send snap rather than a peer append. return { action: { type: "scrollToEnd" }, nextState: liveState } } - // Peer send: only follow if the loaded window is tail-attached to the - // present (`hasMoreNewer` false) AND the viewer was already at/near the - // bottom just BEFORE this append — otherwise leave the "↓ N" pill to - // prompt them back down. if (!hasMoreNewer && isAtEnd && !userScrolledAway) { return { action: { type: "scrollToEnd" }, nextState: liveState } } @@ -397,29 +185,6 @@ export function decideScrollAction(input: DecideScrollActionInput): DecideScroll return { action: { type: "none" }, nextState: baseNextState } } -/** - * Hero-swap scroll compensation. Unlike the deleted `olderPrepended`/ - * `heroSwap` delta-compensation branch this replaces, this is a plain - * arithmetic delta between two known heights — not a `scrollHeight`-diff - * read off the DOM — since the caller already tracks the hero wrapper's - * measured height via `ResizeObserver` (see `useScrollAnchor`). Exported - * for direct unit testing. - */ -export function computeHeroScrollCompensation(prevHeroHeight: number, nextHeroHeight: number): number { - return nextHeroHeight - prevHeroHeight -} - -/** - * Look up a message's position in the flattened item array by id — the - * virtualized replacement for `jumpTo`'s old - * `querySelector('[data-msg-id="..."]')` DOM lookup, which only worked if - * the target row happened to already be mounted. `virtualizer.scrollToIndex` - * needs an INDEX, not a DOM node, so this walks `items` instead. Returns - * `null` when the target isn't in the currently loaded page window — same - * limitation the old DOM lookup had (it also required the row to be - * loaded), just surfaced earlier/more explicitly. Never matches a divider - * row. Exported for direct unit testing. - */ export function findMessageIndex(items: FlatItem[], messageId: string): number | null { for (let i = 0; i < items.length; i++) { const item = items[i] @@ -428,27 +193,10 @@ export function findMessageIndex(items: FlatItem[], messageId: string): number | return null } -/** - * Mount-time scroll target: the NEW divider's own row when present (it's a - * thin line, not the whole message box — centering on the row instead - * visibly biases the divider toward the top of that taller box once the - * anchor message has an attachment/long text/thread preview), falling back - * to the target message's own index when no divider item was flattened - * (e.g. first-visit anchoring with no unread pointer to render a divider - * for). Returns `null` if the target message isn't loaded. - */ export function findMountScrollTargetIndex(items: FlatItem[], newDividerBefore: string): number | null { - const msgIdx = findMessageIndex(items, newDividerBefore) - if (msgIdx === null) return null - const dividerIdx = items.findIndex((i) => i.kind === "new-divider") - return dividerIdx !== -1 ? dividerIdx : msgIdx + return findMessageIndex(items, newDividerBefore) } -/** - * Projects the flattened item array down to just the id/authorId pairs - * `decideScrollAction` needs (it only cares about the tail message's id - * and author, not divider rows). Exported for direct unit testing. - */ export function extractScrollAnchorMessages(items: FlatItem[]): ScrollAnchorMessage[] { const out: ScrollAnchorMessage[] = [] for (const item of items) { @@ -457,658 +205,676 @@ export function extractScrollAnchorMessages(items: FlatItem[]): ScrollAnchorMess return out } -/** - * Owns the message-list scroll container ref, the `useVirtualizer` instance, - * and every automatic scroll-anchor decision (mount / older prepend / - * self-send / peer-follow / hero-swap) plus the "↓ N below" pill's - * `belowCount`. - * - * `jumpTo` (scrolling to an arbitrary earlier message on reply-pill click) - * DOES live here now, unlike the pre-virtualization hook — it needs direct - * `virtualizer.scrollToIndex` access, which `message-list.tsx` has no other - * reason to reach into the virtualizer instance for. - * - * No `channelId`/`dmId` reset param: `` is still keyed by - * `channelId`/`dmId` at the page level, which gives this hook a fresh - * instance — and therefore fresh internal state — on every genuine channel - * switch for free. - */ export function useScrollAnchor({ items, newDividerBefore, initialScrollReady, + scrollToMessageId, + onScrollTargetPositioned, + onScrollTargetCancelled, + hasMoreOlder, hasMoreNewer, isFetchingOlder, isFetchingNewer, presentVersion, viewerUserId, - heroHeight, - heroMeasured, tailPaddingEnd = MESSAGE_RAIL_TAIL_PADDING_END_PX.desktop, onInitialPositionSettled, }: { items: FlatItem[] newDividerBefore?: string initialScrollReady: boolean + scrollToMessageId?: string | null + onScrollTargetPositioned?: (id: string) => void + onScrollTargetCancelled?: (id: string) => void + hasMoreOlder?: boolean hasMoreNewer?: boolean isFetchingOlder?: boolean isFetchingNewer?: boolean presentVersion?: number viewerUserId?: string - // Current measured height (px) of the non-virtualized hero block that - // renders above the virtualized range (the "Beginning of the channel…" - // copy or the thread-opener). Feeds the virtualizer's `scrollMargin` AND - // the hand-rolled hero-swap compensation (see `computeHeroScrollCompensation`) - // — `scrollMargin` alone does NOT preserve scroll position on its own, - // verified against the installed virtual-core source. - heroHeight: number - // True once the caller's hero-height ResizeObserver has fired at least - // once. Gates mount the same way `initialScrollReady` does — see - // `DecideScrollActionInput.heroMeasured`'s doc comment for the bug this - // prevents (mount firing on a stale, default-0 `scrollMargin`). - heroMeasured: boolean tailPaddingEnd?: number onInitialPositionSettled?: () => void -}): { - scrollRef: React.RefObject - virtualizer: ReactVirtualizer - belowCount: number - scrollToBottom: () => void - jumpTo: (messageId: string, behavior?: ScrollBehavior) => void - onImageLoad: () => void - captureOlderPageAnchor: () => void - isOlderPageAnchorSettling: boolean - captureNewerPageAnchor: () => void - isNewerPageAnchorSettling: boolean -} { +}) { + type Kind = "initial" | "target" | "present" | "idle" + type Intent = { + epoch: number + type: "unread" | "target" | "end" + id: string | null + index: number | null + layout: string | null + stableFrames: number + behavior: ScrollBehavior + notifyTarget: boolean + } + type Geometry = { + epoch: number + key: string | number | bigint + prefix: number + itemStart: number + clientHeight: number + scrollHeight: number + scrollTop: number + total: number + paddingEnd: number + exactlyPinned: boolean + isScrolling: boolean + } const scrollRef = useRef(null) + const virtualizerRef = useRef | null>(null) + const positionOwnerRef = useRef({ epoch: 0, kind: "initial" as Kind, active: true, nativeIndex: false }) + const [ownerKind, setOwnerKind] = useAtom(useCreateAtom("initial")) + const [readPositionReady, setReadPositionReady] = useAtom(useCreateAtom(false)) + const [shortGap, setShortGap] = useAtom(useCreateAtom(0)) + const [paginationDirection, setPaginationDirection] = useAtom(useCreateAtom<"older" | "newer" | null>(null)) + const paginationRef = useRef<{ direction: "older" | "newer"; observed: boolean } | null>(null) const stateRef = useRef(createScrollAnchorState()) - const initialSettleFrameRef = useRef(null) + const initialRetiredRef = useRef(false) const initialPositionSettledRef = useRef(false) - const messages = extractScrollAnchorMessages(items) - const tailId = messages[messages.length - 1]?.id ?? null - const wasAtEndRef = useRef(true) - const wasExactlyPinnedRef = useRef(true) + const initialDeadlineRef = useRef(null) + const positionBudgetStartedRef = useRef(false) + const initialSettleFrameRef = useRef(null) + const semanticIntentRef = useRef(null) + const targetIntentRef = useRef(null) + const positionedTargetRef = useRef(null) + const presentIntentEpochRef = useRef(null) + const consumedPresentVersionRef = useRef(0) + const currentItemsRef = useRef(items) + currentItemsRef.current = items + const initialScrollReadyRef = useRef(initialScrollReady) + initialScrollReadyRef.current = initialScrollReady + const onTargetPositionedRef = useRef(onScrollTargetPositioned) + onTargetPositionedRef.current = onScrollTargetPositioned + const onTargetCancelledRef = useRef(onScrollTargetCancelled) + onTargetCancelledRef.current = onScrollTargetCancelled + const settleCallbackRef = useRef(onInitialPositionSettled) + settleCallbackRef.current = onInitialPositionSettled + const readReadyRef = useRef(readPositionReady) + readReadyRef.current = readPositionReady + const wasAtEndRef = useRef(false) const userScrolledAwayRef = useRef(false) - const acceptedClientHeightRef = useRef(0) - const acceptedScrollTopRef = useRef(0) - const measuredRowHeightsRef = useRef(new WeakMap()) - const bottomRepinQueuedRef = useRef(false) - const paginationAnchorRef = useRef(null) - const paginationFetchObservedRef = useRef(false) - const newerPageFetchActiveRef = useRef(false) - const paginationAnchorFrameRef = useRef(null) - const acceptedScrollHeightRef = useRef(0) - const [paginationDirection, setPaginationDirection] = useAtom(useCreateAtom<"older" | "newer" | null>(null)) - const isOlderPageAnchorSettling = paginationDirection === "older" - const isNewerPageAnchorSettling = paginationDirection === "newer" - const previousTailId = stateRef.current.lastTailId - const previousTailIndex = previousTailId === null - ? -1 - : messages.findIndex((message) => message.id === previousTailId) - const firstMeasureAppendIds = initialPositionSettledRef.current - && !isNewerPageAnchorSettling - && previousTailIndex >= 0 - && previousTailIndex < messages.length - 1 - ? new Set(messages.slice(previousTailIndex + 1).map((message) => message.id)) - : null - const liveResizeAnchor = !isNewerPageAnchorSettling - && wasExactlyPinnedRef.current - && !userScrolledAwayRef.current - ? "end" - : "start" - const cancelInitialSettleFrame = useCallback(() => { - if (initialSettleFrameRef.current === null) return - window.cancelAnimationFrame(initialSettleFrameRef.current) + const acceptedGeometryRef = useRef(null) + const geometrySampleRef = useRef<{ value: string; frames: number } | null>(null) + const geometryFrameRef = useRef(null) + const scheduleGeometryRef = useRef<() => void>(() => {}) + const reconcileViewportRef = useRef<() => void>(() => {}) + const userInputRef = useRef({ at: -Infinity, scrollAt: -Infinity, touch: false, pointers: new Set(), handover: false }) + const tailKeyRef = useRef(null) + const messages = useMemo(() => extractScrollAnchorMessages(items), [items]) + const tailId = messages.at(-1)?.id ?? null + const cancelFrame = useCallback(() => { + if (initialSettleFrameRef.current !== null) window.cancelAnimationFrame(initialSettleFrameRef.current) initialSettleFrameRef.current = null }, []) - const scheduleInitialPositionSettled = useCallback(() => { - if (initialPositionSettledRef.current || initialSettleFrameRef.current !== null) return - initialSettleFrameRef.current = window.requestAnimationFrame(() => { - initialSettleFrameRef.current = null - if (initialPositionSettledRef.current) return - initialPositionSettledRef.current = true - onInitialPositionSettled?.() - }) - }, [onInitialPositionSettled]) - useLayoutEffect(() => cancelInitialSettleFrame, [cancelInitialSettleFrame]) - - // eslint-disable-next-line react-hooks/incompatible-library -- library limitation, same as member-list.tsx + const settlePresentation = useCallback(() => { + if (!positionOwnerRef.current.active || initialPositionSettledRef.current) return + initialPositionSettledRef.current = true + settleCallbackRef.current?.() + }, []) + const clearBudget = useCallback(() => { + if (initialDeadlineRef.current !== null) window.clearTimeout(initialDeadlineRef.current) + initialDeadlineRef.current = null + positionBudgetStartedRef.current = false + }, []) + const claimPosition = useCallback((kind: Kind) => { + const owner = positionOwnerRef.current + if (owner.nativeIndex && scrollRef.current) { + virtualizerRef.current?.scrollToOffset(scrollRef.current.scrollTop, { behavior: "auto" }) + } + owner.nativeIndex = false + owner.epoch += 1 + owner.kind = kind + setOwnerKind(kind) + cancelFrame() + clearBudget() + semanticIntentRef.current = null + acceptedGeometryRef.current = null + geometrySampleRef.current = null + presentIntentEpochRef.current = null + readReadyRef.current = false + setReadPositionReady(false) + return owner.epoch + }, [cancelFrame, clearBudget, setOwnerKind, setReadPositionReady]) + const retireInitialPosition = useCallback(() => { + initialRetiredRef.current = true + stateRef.current = { ...stateRef.current, didInitialScroll: true, didDividerConverge: true } + }, []) + const releasePosition = useCallback((userHandover = false) => { + const intent = semanticIntentRef.current + const cancelledTarget = positionOwnerRef.current.kind === "target" + ? intent?.notifyTarget ? intent.id + : !intent && positionedTargetRef.current !== targetIntentRef.current ? targetIntentRef.current : null + : null + const wasReadable = positionOwnerRef.current.kind === "idle" && readReadyRef.current + claimPosition("idle") + if (userHandover && wasReadable) { readReadyRef.current = true; setReadPositionReady(true) } + retireInitialPosition() + positionedTargetRef.current = targetIntentRef.current + if (cancelledTarget) onTargetCancelledRef.current?.(cancelledTarget) + if (userHandover) userInputRef.current.handover = true + settlePresentation() + scheduleGeometryRef.current() + }, [claimPosition, retireInitialPosition, setReadPositionReady, settlePresentation]) + const armBudget = useCallback(() => { + if (positionBudgetStartedRef.current) return + positionBudgetStartedRef.current = true + const epoch = positionOwnerRef.current.epoch + initialDeadlineRef.current = window.setTimeout(() => { + if (!positionOwnerRef.current.active || positionOwnerRef.current.epoch !== epoch) return + releasePosition() + }, INITIAL_POSITION_TIMEOUT_MS) + }, [releasePosition]) + + + const awaitingTarget = !!scrollToMessageId && positionedTargetRef.current !== scrollToMessageId + // eslint-disable-next-line react-hooks/incompatible-library -- supported TanStack Virtual imperative adapter const virtualizer = useVirtualizer({ ...COMMUNITY_VIRTUALIZER_REACT_OPTIONS, count: items.length, getScrollElement: () => scrollRef.current, - estimateSize: (index) => estimateRowHeight(items[index]), - measureElement: (element) => { - const size = measureMessageRow(element) - const previousSize = measuredRowHeightsRef.current.get(element) - measuredRowHeightsRef.current.set(element, size) - const index = Number((element as HTMLElement).dataset.index) - const measuredItem = Number.isInteger(index) ? items[index] : undefined - const isFirstMeasureOfSettledAppend = previousSize === undefined - && measuredItem?.kind === "message" - && firstMeasureAppendIds?.has(measuredItem.m.id) === true - - // `resizeItem` grows the direct-DOM sizer only after this callback - // returns. Its immediate scroll adjustment can therefore be clamped by - // the old scrollHeight. Queue one follow-up after the sizer update so a - // viewer who was already pinned remains pinned; never move somebody who - // deliberately scrolled away. - if ( - previousSize !== size - // Ref callbacks run before the first layout effect. Do not let those - // initial measurements inherit the optimistic `true` latch. Even - // after that effect, anchored mount can still be positioning and - // measuring its initial rows; only a message identity appended after - // initial settlement may give a first measurement re-pin ownership. - && (previousSize !== undefined || isFirstMeasureOfSettledAppend) - // Read the last real scroll sample, not `instance.isAtEnd()` here. - // By the time ResizeObserver calls us, an overflowing child can have - // already increased the browser scrollHeight without emitting a - // scroll event, making a formerly pinned viewport appear far away. - && wasExactlyPinnedRef.current - && !userScrolledAwayRef.current - && !bottomRepinQueuedRef.current - ) { - bottomRepinQueuedRef.current = true - queueMicrotask(() => { - if ( - element.isConnected - && wasExactlyPinnedRef.current - && !userScrolledAwayRef.current - ) { - const scrollElement = scrollRef.current - if (scrollElement) scrollElement.scrollTop = scrollElement.scrollHeight - } - // Markdown/code layout can settle through more than one observer - // callback in the same frame. Keep the batch coalesced and land on - // the final browser max once all of those sizes are reflected. The - // browser's max is authoritative here because it also includes the - // non-virtualized hero above the rows; `scrollToEnd()` operates in - // the virtualizer's scroll-margin coordinate system and can stop by - // exactly that hero height after a row-only resize. - element.ownerDocument.defaultView?.requestAnimationFrame(() => { - bottomRepinQueuedRef.current = false - if ( - element.isConnected - && wasExactlyPinnedRef.current - && !userScrolledAwayRef.current - ) { - const scrollElement = scrollRef.current - if (scrollElement) scrollElement.scrollTop = scrollElement.scrollHeight - } - }) - }) - } - - return size - }, + estimateSize: (index) => estimateRowHeight(items[index]) + + (index === 0 ? (hasMoreOlder ? 88 : 152) : 0) + + (index === items.length - 1 && hasMoreNewer ? 56 : 0), + measureElement: measureMessageRow, getItemKey: (index) => items[index].key, - anchorTo: "end", - // Deliberately OFF — see this file's module doc comment for the - // same-commit double-`scrollToEnd()` race this avoids. + anchorTo: ownerKind === "idle" && readPositionReady && !awaitingTarget ? "end" : "start", followOnAppend: false, - // NOT purely cosmetic even with followOnAppend off — independently - // gates the library's native `resizeItem` above-viewport compensation - // (the mechanism replacing the deleted `watchAsyncGrowth`'s row-level - // image-decode case). Left at the library default (1px), that native - // compensation would only fire when the user is within 1px of the - // literal bottom. - scrollEndThreshold: NEAR_BOTTOM_PX, - scrollMargin: heroHeight, + scrollEndThreshold: 1, + scrollMargin: 0, + paddingStart: shortGap, paddingEnd: tailPaddingEnd, overscan: 8, + onChange: () => { reconcileViewportRef.current(); scheduleGeometryRef.current() }, }) - // virtual-core exposes this predicate on the instance (and `resizeItem` - // reads it there), not through VirtualizerOptions. Assign during render so - // it is already installed when React attaches row refs in the commit. - // Cast: our helper takes a structural subset; Virtualizer keeps some of - // those fields private in the public type. - virtualizer.shouldAdjustScrollPositionOnItemSizeChange = ((item, delta, instance) => { - return shouldAdjustMessageScrollPosition( - item, - delta, - instance as unknown as SizeAdjustmentVirtualizer, - userScrolledAwayRef.current, - ) - }) as typeof virtualizer.shouldAdjustScrollPositionOnItemSizeChange + virtualizerRef.current = virtualizer - // `anchorTo: "end"` is still needed while React applies edge-key changes: - // that is what preserves the visible row across an older-page prepend. - // Between renders, however, virtual-core also uses `anchorTo: "end"` to - // decide whether a row resize should keep the viewport pinned. Its internal - // distance excludes `scrollMargin` (the hero) while its scroll offset - // includes it, so a viewer who moved upward by less than the hero height can - // be mistaken for still-at-end and yanked down by the resize delta. Switch - // only that between-render mode to `start` once the exact-pinned latch is - // false; the next render's `setOptions({ anchorTo: "end" })` still performs - // prepend anchoring before this assignment restores the live resize mode. - virtualizer.options.anchorTo = liveResizeAnchor + const scheduleInitialPositionSettled = useCallback(() => { + if (initialSettleFrameRef.current !== null) return + const epoch = positionOwnerRef.current.epoch + const settle = () => { + const owner = positionOwnerRef.current + if (!owner.active || owner.epoch !== epoch) return + initialSettleFrameRef.current = null + const intent = semanticIntentRef.current + const root = scrollRef.current + const native = virtualizerRef.current + if (!intent || intent.epoch !== owner.epoch || !root || !native) return + const viewport = root.getBoundingClientRect() + if (root.clientHeight <= 0 || viewport.height <= 0) { + initialSettleFrameRef.current = window.requestAnimationFrame(settle) + return + } + let landed = false + if (intent.type === "end") { + const index = currentItemsRef.current.length - 1 + if (intent.index !== index) { + intent.index = index + intent.stableFrames = 0 + owner.nativeIndex = index >= 0 + native.scrollToEnd({ behavior: intent.behavior }) + } + landed = Math.abs(root.scrollHeight - root.clientHeight - root.scrollTop) <= 1 + } else if (intent.id) { + const index = findMessageIndex(currentItemsRef.current, intent.id) + if (index !== null && index !== intent.index) { + intent.index = index + intent.layout = null + intent.stableFrames = 0 + owner.nativeIndex = true + native.scrollToIndex(index, { align: "center", behavior: "auto" }) + } + const row = Array.from(root.querySelectorAll("[data-msg-id]")) + .find((element) => element.dataset.msgId === intent.id) + const boundary = intent.type === "unread" + ? row?.parentElement?.querySelector("[data-new-divider]") + : row + const rect = boundary?.getBoundingClientRect() + const body = row?.getBoundingClientRect() + if (rect && body && rect.height > 0 && body.height > 0) { + const contentCenter = (rect.top + rect.bottom) / 2 - viewport.top + root.scrollTop + const max = Math.max(0, root.scrollHeight - root.clientHeight) + const targetOffset = Math.max(0, Math.min(contentCenter - viewport.height / 2, max)) + const layout = JSON.stringify([index, Math.round(contentCenter), max, viewport.height]) + if (intent.layout !== layout) { + intent.layout = layout + intent.stableFrames = 0 + const cancelNativeIndex = owner.nativeIndex + owner.nativeIndex = false + if (cancelNativeIndex || Math.abs(root.scrollTop - targetOffset) > 1) native.scrollToOffset(targetOffset, { behavior: "auto" }) + } + landed = Math.abs(root.scrollTop - targetOffset) <= 1 + && rect.bottom > viewport.top + 1 && rect.top < viewport.bottom - 1 + && body.bottom > viewport.top + 1 && body.top < viewport.bottom - 1 + } + } + intent.stableFrames = landed ? intent.stableFrames + 1 : 0 + if (intent.stableFrames >= 2) { + settlePresentation() + if (owner.kind === "initial" && !initialScrollReadyRef.current) return + semanticIntentRef.current = null + owner.nativeIndex = false + owner.kind = "idle" + setOwnerKind("idle") + readReadyRef.current = true + setReadPositionReady(true) + clearBudget() + if (intent.notifyTarget && intent.id) { + positionedTargetRef.current = intent.id + onTargetPositionedRef.current?.(intent.id) + } + scheduleGeometryRef.current() + return + } + initialSettleFrameRef.current = window.requestAnimationFrame(settle) + } + initialSettleFrameRef.current = window.requestAnimationFrame(settle) + }, [clearBudget, setOwnerKind, setReadPositionReady, settlePresentation]) + + const startIntent = useCallback((type: Intent["type"], id: string | null, behavior: ScrollBehavior = "auto", notifyTarget = false) => { + const owner = positionOwnerRef.current + const index = type === "end" ? currentItemsRef.current.length - 1 + : id ? findMessageIndex(currentItemsRef.current, id) : null + semanticIntentRef.current = { epoch: owner.epoch, type, id, index, layout: null, stableFrames: 0, behavior, notifyTarget } + if (type === "end") { + owner.nativeIndex = currentItemsRef.current.length > 0 + virtualizerRef.current?.scrollToEnd({ behavior }) + } + else if (index !== null) { + owner.nativeIndex = true + virtualizerRef.current?.scrollToIndex(index, { align: "center", behavior }) + } + armBudget() + scheduleInitialPositionSettled() + }, [armBudget, scheduleInitialPositionSettled]) - const capturePageAnchor = useCallback((direction: "older" | "newer") => { + useLayoutEffect(() => { + const owner = positionOwnerRef.current const root = scrollRef.current - if (!root) return - const rootRect = root.getBoundingClientRect() - const row = Array.from(root.querySelectorAll("[data-msg-id]")) - .find((candidate) => { - const rect = candidate.getBoundingClientRect() - return rect.bottom > rootRect.top + 1 && rect.top < rootRect.bottom - 1 - }) - const messageId = row?.dataset.msgId - // Older-page prepends require a real-message anchor because content is - // inserted above the viewport. Newer pages append below it, so the exact - // scrollTop is itself a stable anchor and also covers the brief moment - // where the virtualizer has not mounted the newly visible end rows yet. - if (direction === "older" && (!row || !messageId)) return - if (paginationAnchorFrameRef.current !== null) { - window.cancelAnimationFrame(paginationAnchorFrameRef.current) - paginationAnchorFrameRef.current = null + owner.active = true + const intent = semanticIntentRef.current + if (intent && intent.epoch !== owner.epoch) { + startIntent(intent.type, intent.id, intent.behavior, intent.notifyTarget) + } else if (owner.kind === "present" && presentIntentEpochRef.current !== null) { + presentIntentEpochRef.current = owner.epoch + armBudget() } - paginationAnchorRef.current = { - direction, - messageId: messageId ?? null, - viewportOffset: row ? row.getBoundingClientRect().top - rootRect.top : null, - scrollTop: root.scrollTop, + return () => { + owner.active = false + owner.epoch += 1 + if (owner.nativeIndex && root) virtualizerRef.current?.scrollToOffset(root.scrollTop, { behavior: "auto" }) + owner.nativeIndex = false + cancelFrame() + clearBudget() + if (geometryFrameRef.current !== null) window.cancelAnimationFrame(geometryFrameRef.current) + geometryFrameRef.current = null } - paginationFetchObservedRef.current = false - newerPageFetchActiveRef.current = direction === "newer" - setPaginationDirection(direction) - }, [setPaginationDirection]) - const captureOlderPageAnchor = useCallback( - () => capturePageAnchor("older"), - [capturePageAnchor], - ) - const captureNewerPageAnchor = useCallback( - () => capturePageAnchor("newer"), - [capturePageAnchor], - ) + }, [armBudget, cancelFrame, clearBudget, startIntent]) useLayoutEffect(() => { - const anchor = paginationAnchorRef.current - if (!anchor) return - const isFetching = anchor.direction === "older" ? isFetchingOlder : isFetchingNewer - if (isFetching) { - paginationFetchObservedRef.current = true + const target = scrollToMessageId ?? null + if (targetIntentRef.current === target) return + targetIntentRef.current = target + if (!target) { + if (positionOwnerRef.current.kind === "target") releasePosition() + positionedTargetRef.current = null return } - if (!paginationFetchObservedRef.current) return - newerPageFetchActiveRef.current = false + claimPosition("target") + retireInitialPosition() + positionedTargetRef.current = null + if (items.length > 0) startIntent("target", target, "auto", true) + }, [claimPosition, items.length, releasePosition, retireInitialPosition, scrollToMessageId, startIntent]) + useLayoutEffect(() => { + if (items.length === 0) return + const owner = positionOwnerRef.current + if (owner.kind === "target") { + if (!semanticIntentRef.current && targetIntentRef.current) startIntent("target", targetIntentRef.current, "auto", true) + else scheduleInitialPositionSettled() + } else if (owner.kind === "initial") armBudget() + }, [armBudget, items, scheduleInitialPositionSettled, startIntent]) + + const readGeometry = useCallback((viewportTransition = false): Geometry | null => { const root = scrollRef.current - const index = anchor.messageId === null ? null : findMessageIndex(items, anchor.messageId) - if (!root || (anchor.direction === "older" && index === null)) { - paginationAnchorRef.current = null - setPaginationDirection(null) - return + const native = virtualizerRef.current + const owner = positionOwnerRef.current + const previous = acceptedGeometryRef.current + const pendingViewportRect = viewportTransition && previous?.epoch === owner.epoch + && previous.clientHeight !== root?.clientHeight + && Math.abs((native?.scrollRect?.height ?? 0) - previous.clientHeight) <= 1 + if (!owner.active || !root || !native || root.clientHeight <= 0 + || Math.abs((native.scrollOffset ?? 0) - root.scrollTop) > 1 + || (!pendingViewportRect && Math.abs((native.scrollRect?.height ?? 0) - root.clientHeight) > 1)) return null + const total = native.getTotalSize() + const max = Math.max(0, root.scrollHeight - root.clientHeight) + if (root.scrollTop < 0 || root.scrollTop > max + 1) return null + const virtualItems = native.getVirtualItems() + const fold = [...virtualItems].reverse().find((item) => item.start <= root.scrollTop + 1) ?? virtualItems[0] + if (!fold) return null + const item = currentItemsRef.current[fold.index] + const wrapper = root.querySelector(`[data-index="${fold.index}"]`) + const body = wrapper?.querySelector("[data-msg-id]") + if (!item || !wrapper || body?.dataset.msgId !== item.m.id || fold.key !== item.key) return null + const wrapperRect = wrapper.getBoundingClientRect() + const bodyRect = body.getBoundingClientRect() + const viewport = root.getBoundingClientRect() + if (wrapperRect.height <= 0 || bodyRect.height <= 0 + || Math.abs(wrapperRect.height - fold.size) > 1 + || Math.abs(wrapperRect.top - viewport.top + root.scrollTop - fold.start) > 1) return null + return { + epoch: owner.epoch, key: fold.key, prefix: bodyRect.top - wrapperRect.top, + itemStart: fold.start, clientHeight: root.clientHeight, + scrollHeight: root.scrollHeight, scrollTop: root.scrollTop, + total, paddingEnd: native.options.paddingEnd ?? 0, exactlyPinned: max - root.scrollTop <= 1, + isScrolling: native.isScrolling, } - const acceptGeometry = () => { - acceptedClientHeightRef.current = root.clientHeight - acceptedScrollTopRef.current = root.scrollTop - acceptedScrollHeightRef.current = root.scrollHeight + }, []) + const observeGeometry = useCallback((next: Geometry, scrollEvent = false) => { + const previous = acceptedGeometryRef.current + const previousFold = previous && virtualizerRef.current?.getVirtualItems().find((item) => item.key === previous.key) + const scrolledAfterResize = scrollEvent && previous?.epoch === next.epoch + && (previous.clientHeight !== next.clientHeight || previous.paddingEnd !== next.paddingEnd) + && previous.scrollTop <= Math.max(0, next.scrollHeight - next.clientHeight) + 1 + && previous.scrollTop !== next.scrollTop + if (positionOwnerRef.current.kind === "idle" && readReadyRef.current + && (!previous || scrolledAfterResize || (previous.epoch === next.epoch + && previous.clientHeight === next.clientHeight && previous.scrollHeight === next.scrollHeight + && previous.total === next.total && previous.paddingEnd === next.paddingEnd + && previousFold?.start === previous.itemStart + && (previous.key !== next.key || previous.prefix === next.prefix)))) { + const unchangedPosition = previous?.epoch === next.epoch + && previous.clientHeight === next.clientHeight && previous.scrollHeight === next.scrollHeight + && virtualizerRef.current?.scrollOffset === previous.scrollTop + && Math.abs(previous.scrollTop - next.scrollTop) <= 1 + acceptedGeometryRef.current = { + ...next, isScrolling: next.isScrolling && unchangedPosition ? previous.isScrolling : next.isScrolling, + } } + }, []) - // Newer pages only add rows below the viewport. Restore the captured - // numeric position synchronously before any tail-follow or measurement - // callback can leave the captured row outside the mounted range. When a - // real row was available at capture time, the rAF loop below additionally - // reconciles its precise visual offset after it remounts. - if (anchor.direction === "newer") root.scrollTop = anchor.scrollTop - if (index !== null) virtualizer.scrollToIndex(index, { align: "start" }) - acceptGeometry() - let attempts = 0 - let stableFrames = 0 - const restore = () => { - paginationAnchorFrameRef.current = null - if (paginationAnchorRef.current !== anchor) return - const row = anchor.messageId === null - ? undefined - : Array.from(root.querySelectorAll("[data-msg-id]")) - .find((candidate) => candidate.dataset.msgId === anchor.messageId) - if (row && anchor.viewportOffset !== null) { - const offset = row.getBoundingClientRect().top - root.getBoundingClientRect().top - const delta = offset - anchor.viewportOffset - if (Math.abs(delta) > 0.5) { - root.scrollTop += delta - stableFrames = 0 - } else { - stableFrames += 1 - } - } else if (anchor.direction === "newer") { - const delta = root.scrollTop - anchor.scrollTop - if (Math.abs(delta) > 0.5) { - root.scrollTop = anchor.scrollTop - stableFrames = 0 - } else { - stableFrames += 1 + const reconcileGeometry = useCallback((viewportOnly = false) => { + const root = scrollRef.current + const native = virtualizerRef.current + const owner = positionOwnerRef.current + if (!owner.active || !root || !native) return false + if (viewportOnly && (owner.kind !== "idle" || !readReadyRef.current + || acceptedGeometryRef.current?.epoch !== owner.epoch + || (acceptedGeometryRef.current.clientHeight === root.clientHeight + && acceptedGeometryRef.current.paddingEnd === (native.options.paddingEnd ?? 0)))) return false + const total = native.getTotalSize() + const gap = Math.max(0, root.clientHeight - (total - (native.options.paddingStart ?? 0))) + if (Math.abs(gap - (native.options.paddingStart ?? 0)) > 1) { + setShortGap(gap) + return true + } + if (owner.kind !== "idle" || (!readReadyRef.current && !userInputRef.current.handover)) return false + const next = readGeometry(viewportOnly) + if (next) observeGeometry(next) + const input = userInputRef.current + const max = Math.max(0, root.scrollHeight - root.clientHeight) + const previous = acceptedGeometryRef.current + const viewportResized = !!next && previous?.epoch === owner.epoch + && (previous.clientHeight !== next.clientHeight || previous.paddingEnd !== next.paddingEnd) + const clampedByResize = viewportResized && !previous.isScrolling && previous.scrollTop > max + && Math.abs(root.scrollTop - max) <= 1 + if (input.touch || input.pointers.size > 0 || (native.isScrolling && !clampedByResize) + || root.scrollTop < 0 || root.scrollTop > max + 1 + || performance.now() - input.at < 180 + || (!clampedByResize && performance.now() - input.scrollAt < 180)) return true + if (!next) return native.getVirtualItems().length > 0 + const virtualItems = native.getVirtualItems() + const value = JSON.stringify(next) + const sample = geometrySampleRef.current + geometrySampleRef.current = { value, frames: sample?.value === value ? sample.frames + 1 : 1 } + if (!viewportResized && geometrySampleRef.current.frames < 2) return true + let offset = root.scrollTop + if (previous?.epoch === owner.epoch && readReadyRef.current) { + const prefixDelta = previous.key === next.key && !previous.exactlyPinned ? next.prefix - previous.prefix : 0 + offset += prefixDelta + if (previous.clientHeight !== next.clientHeight || previous.paddingEnd !== next.paddingEnd) { + const previousFold = virtualItems.find((candidate) => candidate.key === previous.key) + if (previousFold) { + const totalDelta = next.total - previous.total - (next.paddingEnd - previous.paddingEnd) + const adjustedPreviousOffset = previous.scrollTop + + (previous.exactlyPinned ? totalDelta : previousFold.start - previous.itemStart) + const resized = resolveViewportResizeAnchor({ + previousClientHeight: previous.clientHeight, + nextClientHeight: next.clientHeight, + previousScrollHeight: previous.scrollHeight + totalDelta, + nextScrollHeight: next.scrollHeight, + previousScrollTop: adjustedPreviousOffset, + }) + offset = resized.scrollTop + (resized.anchor === "start" ? prefixDelta : 0) } } - acceptGeometry() - attempts += 1 - if (stableFrames >= 2 || attempts >= 12) { - paginationAnchorRef.current = null - setPaginationDirection(null) - return - } - paginationAnchorFrameRef.current = window.requestAnimationFrame(restore) } - paginationAnchorFrameRef.current = window.requestAnimationFrame(restore) - return () => { - if (paginationAnchorFrameRef.current !== null) { - window.cancelAnimationFrame(paginationAnchorFrameRef.current) - paginationAnchorFrameRef.current = null - } + offset = Math.max(0, Math.min(offset, max)) + acceptedGeometryRef.current = { + ...next, scrollTop: offset, exactlyPinned: max - offset <= 1, + isScrolling: clampedByResize ? previous.isScrolling : next.isScrolling, } - }, [isFetchingNewer, isFetchingOlder, items, setPaginationDirection, virtualizer]) - - useLayoutEffect(() => () => { - if (paginationAnchorFrameRef.current !== null) { - window.cancelAnimationFrame(paginationAnchorFrameRef.current) + wasAtEndRef.current = max - offset <= NEAR_BOTTOM_PX + if (input.handover) { + input.handover = false + readReadyRef.current = true + setReadPositionReady(true) } - }, []) + if (Math.abs(offset - root.scrollTop) > 0.5) native.scrollToOffset(offset, { behavior: "auto" }) + return false + }, [observeGeometry, readGeometry, setReadPositionReady, setShortGap]) + reconcileViewportRef.current = () => { reconcileGeometry(true) } + + const scheduleGeometry = useCallback(() => { + if (geometryFrameRef.current !== null || !positionOwnerRef.current.active) return + const accept = () => { + geometryFrameRef.current = null + if (reconcileGeometry()) geometryFrameRef.current = window.requestAnimationFrame(accept) + } + geometryFrameRef.current = window.requestAnimationFrame(accept) + }, [reconcileGeometry]) + scheduleGeometryRef.current = scheduleGeometry + useLayoutEffect(() => { scheduleGeometry() }, [items, ownerKind, readPositionReady, scheduleGeometry, shortGap, tailPaddingEnd]) - // Whether the viewer was within NEAR_BOTTOM_PX of the end BEFORE this - // commit's append — the semantics `decideScrollAction` documents for its - // `isAtEnd` input. It must be sampled from the user's real scroll position, - // NOT `virtualizer.isAtEnd()` read inside the append's own layout effect: - // by then the new message (+ any NEW divider) has already grown the content - // below, so the post-append reading is measured against the NEW, taller - // bottom and reports false even when the viewer was sitting at the old - // bottom — which silently killed peer-follow (the "new message doesn't - // auto-scroll when I'm at the bottom" bug). Appending content below does not - // move `scrollTop`, so it fires no scroll event; this ref therefore still - // holds the pre-append position when the layout effect below reads it. useLayoutEffect(() => { - const el = scrollRef.current - if (!el) return - acceptedClientHeightRef.current = el.clientHeight - acceptedScrollTopRef.current = el.scrollTop - acceptedScrollHeightRef.current = el.scrollHeight - wasExactlyPinnedRef.current = Math.max( - 0, - el.scrollHeight - el.clientHeight - el.scrollTop, - ) <= 1 + const root = scrollRef.current + if (!root) return const onScroll = () => { - const nextScrollTop = el.scrollTop - const paginationAnchor = paginationAnchorRef.current - if (newerPageFetchActiveRef.current && paginationAnchor?.direction === "newer") { - const rootRect = el.getBoundingClientRect() - const row = Array.from(el.querySelectorAll("[data-msg-id]")) - .find((candidate) => { - const rect = candidate.getBoundingClientRect() - return rect.bottom > rootRect.top + 1 && rect.top < rootRect.bottom - 1 - }) - paginationAnchor.scrollTop = nextScrollTop - paginationAnchor.messageId = row?.dataset.msgId ?? null - paginationAnchor.viewportOffset = row - ? row.getBoundingClientRect().top - rootRect.top - : null - } - const isAtEnd = virtualizer.isAtEnd(NEAR_BOTTOM_PX) - const leftEnd = wasAtEndRef.current && !isAtEnd - wasAtEndRef.current = isAtEnd - if (isAtEnd) { - userScrolledAwayRef.current = false - virtualizer.options.anchorTo = "end" - } else if (leftEnd || nextScrollTop < acceptedScrollTopRef.current - 1) { - userScrolledAwayRef.current = true - virtualizer.options.anchorTo = "start" - } - - // A scroll dispatched while the viewport height differs from the last - // ResizeObserver sample belongs to that pending resize. It cannot tell - // us whether the viewer was exactly pinned before layout changed. - if (el.clientHeight === acceptedClientHeightRef.current) { - const distanceToEnd = Math.max(0, el.scrollHeight - el.clientHeight - nextScrollTop) - if (distanceToEnd > 1 || nextScrollTop < acceptedScrollTopRef.current - 1) { - wasExactlyPinnedRef.current = false - } else if (nextScrollTop > acceptedScrollTopRef.current) { - // False may only recover from a real, stable scroll toward the end. - // Same-position or decreasing browser-clamp events at the end must - // preserve false for the next resize. - wasExactlyPinnedRef.current = true - } - acceptedScrollTopRef.current = nextScrollTop - acceptedScrollHeightRef.current = el.scrollHeight - } - virtualizer.options.anchorTo = wasExactlyPinnedRef.current && !userScrolledAwayRef.current - ? "end" - : "start" + const input = userInputRef.current + const previous = acceptedGeometryRef.current + const unchanged = previous?.epoch === positionOwnerRef.current.epoch + && previous.clientHeight === root.clientHeight && previous.scrollHeight === root.scrollHeight + && virtualizerRef.current?.scrollOffset === previous.scrollTop + && Math.abs(previous.scrollTop - root.scrollTop) <= 1 + if (!unchanged) input.scrollAt = performance.now() + reconcileGeometry(true) + const distance = Math.max(0, root.scrollHeight - root.clientHeight - root.scrollTop) + wasAtEndRef.current = distance <= NEAR_BOTTOM_PX + if (wasAtEndRef.current) userScrolledAwayRef.current = false + const next = readGeometry() + if (next) observeGeometry(next, true) + scheduleGeometry() } - const onWheel = (event: WheelEvent) => { - if (event.deltaY < 0) { - wasExactlyPinnedRef.current = false - userScrolledAwayRef.current = true - virtualizer.options.anchorTo = "start" - } + const onUserIntent = () => { + userInputRef.current.at = performance.now() + userScrolledAwayRef.current = true + releasePosition(true) } + const onWheel = (event: WheelEvent) => { if (event.deltaY !== 0) onUserIntent() } const onKeyDown = (event: KeyboardEvent) => { - if (["ArrowUp", "PageUp", "Home"].includes(event.key)) { - wasExactlyPinnedRef.current = false - userScrolledAwayRef.current = true - virtualizer.options.anchorTo = "start" - } + if (["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "].includes(event.key)) onUserIntent() } - el.addEventListener("scroll", onScroll, { passive: true }) - el.addEventListener("wheel", onWheel, { passive: true }) - el.addEventListener("keydown", onKeyDown) + const onTouchStart = () => { userInputRef.current.touch = true; onUserIntent() } + const onTouchMove = () => { userInputRef.current.at = performance.now() } + const onTouchEnd = (event: TouchEvent) => { + userInputRef.current.touch = event.touches.length > 0 + userInputRef.current.at = performance.now() + scheduleGeometry() + } + const onPointerDown = (event: PointerEvent) => { userInputRef.current.pointers.add(event.pointerId); onUserIntent() } + const onPointerUp = (event: PointerEvent) => { + if (!userInputRef.current.pointers.delete(event.pointerId)) return + userInputRef.current.at = performance.now() + scheduleGeometry() + } + const onBlur = () => { + userInputRef.current.touch = false + userInputRef.current.pointers.clear() + scheduleGeometry() + } + root.addEventListener("scroll", onScroll, { passive: true }) + root.addEventListener("wheel", onWheel, { passive: true }) + root.addEventListener("keydown", onKeyDown) + root.addEventListener("touchstart", onTouchStart, { passive: true }) + root.addEventListener("touchmove", onTouchMove, { passive: true }) + root.addEventListener("touchend", onTouchEnd, { passive: true }) + root.addEventListener("touchcancel", onTouchEnd, { passive: true }) + root.addEventListener("pointerdown", onPointerDown) + root.ownerDocument.addEventListener("pointerup", onPointerUp) + root.ownerDocument.addEventListener("pointercancel", onPointerUp) + root.ownerDocument.defaultView?.addEventListener("blur", onBlur) + const ro = new ResizeObserver(() => { reconcileGeometry(true); scheduleGeometry() }) + ro.observe(root) + const footer = root.closest('[data-slot="community-conversation-surface"]') + ?.querySelector('[data-slot="community-conversation-footer"]') + const mo = footer && typeof MutationObserver !== "undefined" + ? new MutationObserver(() => { reconcileGeometry(true); scheduleGeometry() }) : null + mo?.observe(footer!, { attributes: true, characterData: true, childList: true, subtree: true }) return () => { - el.removeEventListener("scroll", onScroll) - el.removeEventListener("wheel", onWheel) - el.removeEventListener("keydown", onKeyDown) + root.removeEventListener("scroll", onScroll) + root.removeEventListener("wheel", onWheel) + root.removeEventListener("keydown", onKeyDown) + root.removeEventListener("touchstart", onTouchStart) + root.removeEventListener("touchmove", onTouchMove) + root.removeEventListener("touchend", onTouchEnd) + root.removeEventListener("touchcancel", onTouchEnd) + root.removeEventListener("pointerdown", onPointerDown) + root.ownerDocument.removeEventListener("pointerup", onPointerUp) + root.ownerDocument.removeEventListener("pointercancel", onPointerUp) + root.ownerDocument.defaultView?.removeEventListener("blur", onBlur) + ro.disconnect() + mo?.disconnect() + userInputRef.current.touch = false + userInputRef.current.pointers.clear() + } + }, [observeGeometry, readGeometry, reconcileGeometry, releasePosition, scheduleGeometry]) + + const capturePageAnchor = useCallback((direction: "older" | "newer") => { + if (positionOwnerRef.current.kind === "idle" && readReadyRef.current) { + const geometry = readGeometry() + if (geometry) acceptedGeometryRef.current = geometry } - }, [virtualizer]) + paginationRef.current = { direction, observed: false } + setPaginationDirection(direction) + }, [readGeometry, setPaginationDirection]) + const captureOlderPageAnchor = useCallback(() => capturePageAnchor("older"), [capturePageAnchor]) + const captureNewerPageAnchor = useCallback(() => capturePageAnchor("newer"), [capturePageAnchor]) + useLayoutEffect(() => { + const page = paginationRef.current + if (!page) return + const fetching = page.direction === "older" ? isFetchingOlder : isFetchingNewer + if (fetching) { page.observed = true; return } + if (!page.observed) return + paginationRef.current = null + setPaginationDirection(null) + stateRef.current = { ...stateRef.current, lastTailId: tailId } + tailKeyRef.current = items.at(-1)?.key ?? null + scheduleInitialPositionSettled() + scheduleGeometry() + }, [isFetchingNewer, isFetchingOlder, items, scheduleGeometry, scheduleInitialPositionSettled, setPaginationDirection, tailId]) useLayoutEffect(() => { + if (!presentVersion || !tailId || hasMoreNewer || consumedPresentVersionRef.current === presentVersion) return + consumedPresentVersionRef.current = presentVersion + stateRef.current = { ...stateRef.current, lastTailId: tailId } + tailKeyRef.current = items.at(-1)?.key ?? null + const owner = positionOwnerRef.current + if (!owner.active || owner.kind !== "present" || presentIntentEpochRef.current !== owner.epoch) return + presentIntentEpochRef.current = null + retireInitialPosition() + userScrolledAwayRef.current = false + startIntent("end", null) + }, [hasMoreNewer, items, presentVersion, retireInitialPosition, startIntent, tailId]) + + const virtualItems = virtualizer.getVirtualItems() + useLayoutEffect(() => { + const owner = positionOwnerRef.current + const root = scrollRef.current + const viewportReady = !!root && root.clientHeight > 0 && virtualItems.length > 0 + && !!root.querySelector("[data-index]") + if (owner.kind === "target" || owner.kind === "present") { + stateRef.current = { ...stateRef.current, lastTailId: tailId } + tailKeyRef.current = items.at(-1)?.key ?? null + return + } + if (owner.kind === "initial" && stateRef.current.didInitialScroll) { + stateRef.current = { ...stateRef.current, lastTailId: tailId } + tailKeyRef.current = items.at(-1)?.key ?? null + if (!stateRef.current.didDividerConverge && initialScrollReady && viewportReady) { + stateRef.current = { ...stateRef.current, didDividerConverge: true } + startIntent(newDividerBefore ? "unread" : "end", newDividerBefore ?? null) + } else scheduleInitialPositionSettled() + return + } + const previousTailKey = tailKeyRef.current + const nextTailKey = items.at(-1)?.key ?? null const { action, nextState } = decideScrollAction({ state: stateRef.current, messages, newDividerBefore, initialScrollReady, - heroMeasured, + viewportReady, hasMoreNewer, - isPaginatingNewer: isNewerPageAnchorSettling, + isPaginatingNewer: !!isFetchingNewer || paginationDirection === "newer", viewerUserId, isAtEnd: wasAtEndRef.current, userScrolledAway: userScrolledAwayRef.current, }) - stateRef.current = nextState - const initialSequenceComplete = nextState.didInitialScroll && nextState.didDividerConverge - if (!initialSequenceComplete) cancelInitialSettleFrame() - - switch (action.type) { - case "mount": { - const idx = action.newDividerBefore ? findMountScrollTargetIndex(items, action.newDividerBefore) : null - if (idx !== null) { - virtualizer.scrollToIndex(idx, { align: "center" }) - } else { - wasExactlyPinnedRef.current = true - virtualizer.options.anchorTo = "end" - virtualizer.scrollToEnd() - } - break - } - case "scrollToEnd": - wasExactlyPinnedRef.current = true - virtualizer.options.anchorTo = "end" - virtualizer.scrollToEnd() - break - case "none": - break - } - if (initialSequenceComplete) scheduleInitialPositionSettled() - // messages/items share identity per render (extractScrollAnchorMessages - // derives from items) — `items` alone is the correct dep, not a - // secondary `messages` dep, avoiding a re-derivation-triggered re-fire. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - items, - newDividerBefore, - initialScrollReady, - heroMeasured, - hasMoreNewer, - isNewerPageAnchorSettling, - viewerUserId, - virtualizer, - cancelInitialSettleFrame, - scheduleInitialPositionSettled, - ]) - - const consumedPresentVersionRef = useRef(0) - useLayoutEffect(() => { - if (!presentVersion || !tailId || hasMoreNewer) return - if (consumedPresentVersionRef.current === presentVersion) return - consumedPresentVersionRef.current = presentVersion - stateRef.current = { - didInitialScroll: true, - didDividerConverge: true, - lastTailId: tailId, - } - wasAtEndRef.current = true - wasExactlyPinnedRef.current = true - userScrolledAwayRef.current = false - virtualizer.options.anchorTo = "end" - virtualizer.scrollToEnd() - scheduleInitialPositionSettled() - }, [hasMoreNewer, presentVersion, scheduleInitialPositionSettled, tailId, virtualizer]) - - // Hero-swap compensation — NOT delegated to `scrollMargin` (verified it - // never triggers a `scrollOffset` write on its own). Tracks the hero's - // height across renders and adjusts `el.scrollTop` by the delta whenever - // it changes, holding the visually-anchored row in place. Narrower than - // the deleted `watchAsyncGrowth`: only one input (a single number this - // hook already receives as a prop), no ResizeObserver of its own needed - // here — the caller (`message-list.tsx`) owns the hero's own - // ResizeObserver and passes the resulting height in as `heroHeight`. - const prevHeroHeightRef = useRef(heroHeight) - useLayoutEffect(() => { - const el = scrollRef.current - if (!el) return - const delta = computeHeroScrollCompensation(prevHeroHeightRef.current, heroHeight) - prevHeroHeightRef.current = heroHeight - if (delta !== 0) el.scrollTop += delta - }, [heroHeight]) - - // Viewport resize compensation — composer growth/shrink and mobile-keyboard - // changes both alter this viewport's real `clientHeight`. Preserve the - // conversation's semantic anchor from the last accepted geometry: exact - // distance-to-tail through the shared 100px tail context, otherwise the - // reading scrollTop. This hook remains the sole writer for footer-driven - // viewport changes; the footer itself does not measure or mutate the list. - // - // A footer DOM mutation is observed in the microtask that created it so the - // correction lands before the next animation frame. Waiting only for the - // scroller ResizeObserver leaves one frame where the browser has already - // clamped scrollTop to its new maximum (a visible 100px flash at the tail - // boundary). ResizeObserver remains the fallback for pure layout changes - // such as the mobile keyboard. Both paths share and immediately accept the - // same geometry, so a later delivery is a no-op rather than a double-write. - useLayoutEffect(() => { - const el = scrollRef.current - if (!el) return - // First-fire guard: ResizeObserver fires immediately on observe() with - // the current size. Seed the previous height first so that initial - // callback computes a zero delta instead of a spurious jump at mount. - acceptedClientHeightRef.current = el.clientHeight - acceptedScrollTopRef.current = el.scrollTop - acceptedScrollHeightRef.current = el.scrollHeight - const reconcileViewportResize = () => { - const previousClientHeight = acceptedClientHeightRef.current - const nextClientHeight = el.clientHeight - if (nextClientHeight === previousClientHeight) return - const result = resolveViewportResizeAnchor({ - previousClientHeight, - nextClientHeight, - previousScrollHeight: acceptedScrollHeightRef.current, - nextScrollHeight: el.scrollHeight, - previousScrollTop: acceptedScrollTopRef.current, - }) - if (Math.abs(el.scrollTop - result.scrollTop) > 0.5) { - el.scrollTop = result.scrollTop + stateRef.current = initialRetiredRef.current + ? { ...nextState, didInitialScroll: true, didDividerConverge: true } + : nextState + tailKeyRef.current = nextTailKey + if (action.type === "mount" && owner.kind === "initial") { + startIntent(action.newDividerBefore ? "unread" : "end", action.newDividerBefore ?? null) + } else if (action.type === "scrollToEnd") { + if (owner.kind === "initial") startIntent("end", null) + else if (readReadyRef.current && previousTailKey !== nextTailKey) { + claimPosition("present") + retireInitialPosition() + userScrolledAwayRef.current = false + startIntent("end", null) } - acceptedClientHeightRef.current = nextClientHeight - acceptedScrollTopRef.current = el.scrollTop - acceptedScrollHeightRef.current = el.scrollHeight - wasAtEndRef.current = result.distanceToEnd <= NEAR_BOTTOM_PX - wasExactlyPinnedRef.current = result.distanceToEnd <= 1 - virtualizer.options.anchorTo = wasExactlyPinnedRef.current && !userScrolledAwayRef.current - ? "end" - : "start" - } - const ro = new ResizeObserver(reconcileViewportResize) - ro.observe(el) - const footer = el - .closest('[data-slot="community-conversation-surface"]') - ?.querySelector('[data-slot="community-conversation-footer"]') - const mo = footer && typeof MutationObserver !== "undefined" - ? new MutationObserver(reconcileViewportResize) - : null - mo?.observe(footer!, { - attributes: true, - characterData: true, - childList: true, - subtree: true, - }) - return () => { - ro.disconnect() - mo?.disconnect() - } - }, [virtualizer]) - - // "↓ N below" pill count — a plain arithmetic derivation from data - // `getVirtualItems()` already exposes on every render, replacing the - // pre-virtualization `recomputeBelow`'s DOM-row-walk - // (`querySelectorAll("[data-msg-id]")` + `offsetTop` comparison). - // `virtualizer.range?.endIndex` is the last VISIBLE index BEFORE overscan - // is applied (overscan is added only in `defaultRangeExtractor`, not in - // `range`), so counting below it doesn't treat the 8 overscanned off-screen - // rows as visible and deflate the badge. `null` before the first - // measurement → `-1` → `computeBelowCount` returns 0 (nothing measured - // yet), which is correct. - const lastVisibleIndex = virtualizer.range?.endIndex ?? -1 - const belowCount = virtualizer.isAtEnd(NEAR_BOTTOM_PX) ? 0 : computeBelowCount(items, lastVisibleIndex) - + } else if (semanticIntentRef.current) scheduleInitialPositionSettled() + }, [claimPosition, hasMoreNewer, initialScrollReady, isFetchingNewer, items, messages, newDividerBefore, paginationDirection, retireInitialPosition, scheduleInitialPositionSettled, startIntent, tailId, viewerUserId, virtualItems, virtualizer]) + + const requestPresentPosition = useCallback(() => { + presentIntentEpochRef.current = claimPosition("present") + retireInitialPosition() + armBudget() + }, [armBudget, claimPosition, retireInitialPosition]) const scrollToBottom = useCallback(() => { + claimPosition("present") + retireInitialPosition() userScrolledAwayRef.current = false - wasAtEndRef.current = true - wasExactlyPinnedRef.current = true - virtualizer.options.anchorTo = "end" - virtualizer.scrollToEnd({ behavior: "smooth" }) - }, [virtualizer]) - - // Re-pin after an attachment image finishes loading, but only if the - // viewer was exactly pinned before that growth — restores the deleted - // `watchAsyncGrowth` image-decode re-scroll narrowly, per-image and gated, - // so a dimensionless image that grows after `scrollToEnd` already fired - // still lands the message's bottom at the viewport bottom, while never - // yanking a reader even 2px away. Instant (no smooth) so it doesn't animate - // on every image load. - const onImageLoad = useCallback(() => { - if (wasExactlyPinnedRef.current && !userScrolledAwayRef.current) { - wasExactlyPinnedRef.current = true - virtualizer.options.anchorTo = "end" - virtualizer.scrollToEnd() - } - }, [virtualizer]) - + startIntent("end", null, "smooth") + }, [claimPosition, retireInitialPosition, startIntent]) const jumpTo = useCallback((messageId: string, behavior: ScrollBehavior = "smooth") => { - const idx = findMessageIndex(items, messageId) - // Target not in the currently loaded page window — same limitation the - // pre-virtualization `querySelector` lookup had (it also required the - // row to be loaded); documented no-op, not a new failure mode. - if (idx === null) return - virtualizer.scrollToIndex(idx, { align: "center", behavior }) - }, [items, virtualizer]) - + if (findMessageIndex(currentItemsRef.current, messageId) === null) return + claimPosition("target") + retireInitialPosition() + startIntent("target", messageId, behavior) + }, [claimPosition, retireInitialPosition, startIntent]) + const visibleItems = virtualizer.getVirtualItems() + const root = scrollRef.current + const lastVisibleIndex = root + ? visibleItems.filter((item) => item.start < root.scrollTop + root.clientHeight).at(-1)?.index ?? -1 + : -1 + const belowCount = root && root.scrollHeight - root.clientHeight - root.scrollTop <= NEAR_BOTTOM_PX + ? 0 : computeBelowCount(items, lastVisibleIndex) return { - scrollRef, - virtualizer, - belowCount, - scrollToBottom, - jumpTo, - onImageLoad, - captureOlderPageAnchor, - isOlderPageAnchorSettling, - captureNewerPageAnchor, - isNewerPageAnchorSettling, + scrollRef, virtualizer, readPositionReady, belowCount, + scrollToBottom, requestPresentPosition, jumpTo, + captureOlderPageAnchor, captureNewerPageAnchor, + isOlderPageAnchorSettling: paginationDirection === "older", + isNewerPageAnchorSettling: paginationDirection === "newer", } } diff --git a/src/web/src/hooks/community/use-server-cold.dom.test.tsx b/src/web/src/hooks/community/use-server-cold.dom.test.tsx index ee60a1e66..ecd97dcf4 100644 --- a/src/web/src/hooks/community/use-server-cold.dom.test.tsx +++ b/src/web/src/hooks/community/use-server-cold.dom.test.tsx @@ -1,9 +1,11 @@ import React from "react" import { QueryClient } from "@tanstack/react-query" import { afterEach, describe, expect, it, vi } from "vitest" -import { renderHook, waitFor } from "@/test/react-dom-harness" +import { act, renderHook, waitFor } from "@/test/react-dom-harness" import { CommunityTestProvider } from "@/test/community-owner-fixture" import { useServer, useServers } from "./use-servers" +import { CONVERSATION_READ_TIMEOUT_MS } from "@/lib/community/conversation-read" +import { communityKeys } from "@/lib/query-keys" import { getCommunityDbRegistry } from "@/lib/community-db/collections" const api = vi.hoisted(() => vi.fn()) vi.mock("@/lib/api/client", () => ({ apiFetch: api })) @@ -16,6 +18,29 @@ function respond(path: string) { return list } describe("actual cold server native read", () => { + it("settles a server detail while a shared identity read remains useful to the rail, then retries", async () => { + let release!: (value: typeof list) => void + let identitySignal!: AbortSignal + api.mockImplementation((path: string, options: { signal: AbortSignal }) => path === "/api/community/servers" ? new Promise((done) => { release = done; identitySignal = options.signal }) : Promise.resolve(respond(path))) + const qc = client() + const wrapper = ({ children }: { children: React.ReactNode }) => {children} + vi.useFakeTimers() + let view!: ReturnType; rail: ReturnType }, unknown>> + try { + view = renderHook(() => ({ server: useServer("srv"), rail: useServers() }), { wrapper }) + await vi.waitFor(() => expect(api.mock.calls.filter(([path]) => path === "/api/community/servers")).toHaveLength(1)) + await act(async () => { await vi.advanceTimersByTimeAsync(CONVERSATION_READ_TIMEOUT_MS) }) + expect(qc.getQueryState(communityKeys.server("srv"))?.error).toMatchObject({ name: "ConversationReadTimeoutError" }) + expect(identitySignal.aborted).toBe(false) + } finally { vi.useRealTimers() } + await act(async () => { release(list) }) + await waitFor(() => expect(view.result.current.rail.servers[0]?.name).toBe("Cold")) + expect(view.result.current.server.server).toBeNull() + await act(async () => { await view.result.current.server.refetch() }) + await waitFor(() => expect(view.result.current.server.server?.name).toBe("Cold")) + view.unmount() + }) + it("loads identity without a rail observer and publishes canonical DB", async () => { api.mockImplementation(async (path: string) => respond(path)) const qc = client() diff --git a/src/web/src/hooks/community/use-servers.dom.test.ts b/src/web/src/hooks/community/use-servers.dom.test.ts index 9cb1b5a22..c4ed62500 100644 --- a/src/web/src/hooks/community/use-servers.dom.test.ts +++ b/src/web/src/hooks/community/use-servers.dom.test.ts @@ -232,6 +232,45 @@ describe("useServers / serversQueryFn", () => { }) describe("useServer / projected canonical detail", () => { + it("retains a current server deadline failure across another native consumer and recovers on explicit retry", async () => { + seedList() + const key = communityKeys.server(identity.id) + client.setQueryDefaults(key, { retry: 1, retryDelay: 0 }) + let categorySignal!: AbortSignal + apiFetchMock.mockImplementation((url: string, options: { signal: AbortSignal }) => url.endsWith("/categories") + ? new Promise(() => { categorySignal = options.signal }) : Promise.resolve(detailApi(url))) + vi.useFakeTimers() + const first = renderHook(() => useServer(identity.id), { wrapper: Owner }) + let second: ReturnType | undefined + try { + await act(async () => { await vi.advanceTimersByTimeAsync(0) }) + expect(categorySignal).toBeDefined() + const originalQuery = client.getQueryCache().find({ queryKey: key, exact: true })! + await act(async () => { await vi.advanceTimersByTimeAsync(15_100) }) + expect(first.result.current.isError).toBe(true) + expect(first.result.current.fetchStatus).toBe("idle") + expect(categorySignal.aborted).toBe(true) + expect(originalQuery.state.fetchFailureCount).toBe(1) + second = renderHook(() => useServer(identity.id), { wrapper: Owner }) + await act(async () => { await vi.advanceTimersByTimeAsync(100) }) + expect(first.result.current.isError).toBe(true) + expect(first.result.current.fetchStatus).toBe("idle") + expect(client.getQueryCache().find({ queryKey: key, exact: true })).toBe(originalQuery) + expect(apiFetchMock.mock.calls.filter(([path]) => String(path).endsWith("/categories"))).toHaveLength(1) + apiFetchMock.mockImplementation((url: string) => Promise.resolve(detailApi(url))) + await act(async () => { await first.result.current.refetch() }) + await act(async () => { await vi.advanceTimersByTimeAsync(100) }) + expect(first.result.current.server?.id).toBe(identity.id) + expect(first.result.current.isError).toBe(false) + expect(client.getQueryCache().find({ queryKey: key, exact: true })).toBe(originalQuery) + expect(apiFetchMock.mock.calls.filter(([path]) => String(path).endsWith("/categories"))).toHaveLength(2) + } finally { + second?.unmount() + first.unmount() + vi.useRealTimers() + } + }) + it.each([403, 404])("hides retained server detail after a definitive %s", async (status) => { seedDetail() const rendered = renderHook(() => useServer(identity.id), { wrapper: Owner }) @@ -342,10 +381,19 @@ describe("useServer / projected canonical detail", () => { }) it("passes the navigation AbortSignal to every server-detail resource", async () => { - seedList(); apiFetchMock.mockImplementation(detailApi) - let signal!: AbortSignal - await client.fetchQuery({ queryKey: communityKeys.server(identity.id), queryFn: (context) => { signal = context.signal; return serverProjectedQueryFn(client, identity.id, signal)() } }) - for (const path of ["categories", "channels"]) expect(apiFetchMock).toHaveBeenCalledWith(`/api/community/servers/${identity.id}/${path}`, expect.objectContaining({ signal, assertActive: expect.any(Function) })) + seedList() + apiFetchMock.mockImplementation(() => new Promise(() => {})) + const controller = new AbortController() + const result = serverProjectedQueryFn(client, identity.id, controller.signal)() + const rejected = expect(result).rejects.toMatchObject({ name: "AbortError" }) + await waitFor(() => expect(apiFetchMock).toHaveBeenCalledTimes(2)) + const signals = apiFetchMock.mock.calls.map(([, options]) => options.signal as AbortSignal) + for (const path of ["categories", "channels"]) expect(apiFetchMock).toHaveBeenCalledWith(`/api/community/servers/${identity.id}/${path}`, expect.objectContaining({ signal: expect.any(AbortSignal), assertActive: expect.any(Function) })) + expect(signals.every((signal) => !signal.aborted)).toBe(true) + controller.abort(new DOMException("Retired navigation", "AbortError")) + await rejected + expect(signals.every((signal) => signal.aborted)).toBe(true) + expect(client.getQueryData(communityKeys.server(identity.id))).toBeUndefined() }) it("resolves warm canonical detail without joining an in-flight rail replacement", async () => { diff --git a/src/web/src/hooks/community/use-servers.ts b/src/web/src/hooks/community/use-servers.ts index 1ae0fbd2d..89ad51f6f 100644 --- a/src/web/src/hooks/community/use-servers.ts +++ b/src/web/src/hooks/community/use-servers.ts @@ -1,5 +1,6 @@ "use client" import { communityRequestOptions } from "@/lib/community/account-cache-lifecycle" +import { conversationReadRetryPolicy, withConversationReadDeadline } from "@/lib/community/conversation-read" import { useSelector } from "@tanstack/react-store" @@ -308,33 +309,35 @@ export const serverProjectedQueryFn = ( const family = `server-detail:${serverId}` as const const token = projection.beginSnapshot(family, "channels") try { - await structuralToken.registry?.ready - assertCommunityLiveSnapshotTokenCurrent(queryClient, structuralToken, signal) - await Promise.all([ - structuralToken.registry!.collections.categories.preload(), - structuralToken.registry!.collections.channels.preload(), - structuralToken.registry!.collections.channelMemberships.preload(), - ]) - assertCommunityLiveSnapshotTokenCurrent(queryClient, structuralToken, signal) - const data = await serverQueryFn(queryClient, serverId, signal)() - assertCommunityLiveSnapshotTokenCurrent(queryClient, structuralToken, signal) - const confirmedAccessScopes: AccountUnreadScope[] = [ - { kind: "server", serverId }, - ...data.categories.flatMap((category) => category.channels.map((channel) => ({ - kind: "channel" as const, - channelId: channel.id, - }))), - ] - projection.absorbSnapshot( - token, - [], - { confirmedAccessScopes }, - ) - publishCommunityLiveSnapshot(queryClient, { - snapshot: { kind: "server-detail", data }, - proof: { kind: "structural", token: structuralToken, signal }, + return await withConversationReadDeadline(signal, async (readSignal) => { + await structuralToken.registry?.ready + assertCommunityLiveSnapshotTokenCurrent(queryClient, structuralToken, readSignal) + await Promise.all([ + structuralToken.registry!.collections.categories.preload(), + structuralToken.registry!.collections.channels.preload(), + structuralToken.registry!.collections.channelMemberships.preload(), + ]) + assertCommunityLiveSnapshotTokenCurrent(queryClient, structuralToken, readSignal) + const data = await serverQueryFn(queryClient, serverId, readSignal)() + assertCommunityLiveSnapshotTokenCurrent(queryClient, structuralToken, readSignal) + const confirmedAccessScopes: AccountUnreadScope[] = [ + { kind: "server", serverId }, + ...data.categories.flatMap((category) => category.channels.map((channel) => ({ + kind: "channel" as const, + channelId: channel.id, + }))), + ] + projection.absorbSnapshot( + token, + [], + { confirmedAccessScopes }, + ) + publishCommunityLiveSnapshot(queryClient, { + snapshot: { kind: "server-detail", data }, + proof: { kind: "structural", token: structuralToken, signal: readSignal }, + }) + return data.id }) - return data.id } catch (error) { projection.cancelSnapshot(token) if (error instanceof ApiError && (error.status === 403 || error.status === 404)) { @@ -368,6 +371,9 @@ export function useServer( enabled, staleTime: Infinity, refetchOnReconnect: true, + retryOnMount: false, + retry: conversationReadRetryPolicy(queryClient.defaultQueryOptions({ queryKey: communityKeys.server(serverId ?? "__none__") }).retry), + networkMode: "always", }) const projectedServer = useMemo(() => { const source = dbServer diff --git a/src/web/src/hooks/community/use-virtual-cursor-sentinel.dom.test.ts b/src/web/src/hooks/community/use-virtual-cursor-sentinel.dom.test.ts index 61f32cdce..658bab832 100644 --- a/src/web/src/hooks/community/use-virtual-cursor-sentinel.dom.test.ts +++ b/src/web/src/hooks/community/use-virtual-cursor-sentinel.dom.test.ts @@ -1,9 +1,12 @@ -import { createElement, useLayoutEffect, useRef } from "react" +import { createElement, useCallback, useRef } from "react" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { act, fireEvent, render } from "@/test/react-dom-harness" import { useVirtualCursorSentinel } from "./use-virtual-cursor-sentinel" type Props = { + showSentinel?: boolean + elementRevision?: number + rootRevision?: number edge: "start" | "end" hasMore: boolean isFetching: boolean @@ -19,18 +22,12 @@ let observed: Element | undefined function Harness(props: Props) { const scrollRef = useRef(null) const sentinelRef = useVirtualCursorSentinel({ scrollRef, ...props }) - useLayoutEffect(() => { - scrollRef.current = document.querySelector('[data-testid="scroll"]') - sentinelRef.current = document.querySelector('[data-testid="sentinel"]') - return () => { - scrollRef.current = null - sentinelRef.current = null - } - }, [sentinelRef]) + const bindRoot = useCallback((node: HTMLDivElement | null) => { scrollRef.current = node }, []) return createElement( "div", - { "data-testid": "scroll" }, - createElement("div", { "data-testid": "sentinel" }), + // eslint-disable-next-line react-hooks/refs -- React DOM invokes these ref callbacks during commit. + { "data-testid": "scroll", key: props.rootRevision ?? 0, ref: bindRoot }, + props.showSentinel === false ? null : createElement("div", { "data-testid": "sentinel", key: props.elementRevision ?? 0, ref: sentinelRef }), ) } @@ -282,4 +279,86 @@ describe.each(["start", "end"] as const)("useVirtualCursorSentinel (%s edge)", ( view.unmount() expect(disconnect).toHaveBeenCalled() }) + + it("preserves the demanded intersection and rejects the detached observer callback", () => { + const onLoad = vi.fn() + const base = { edge, hasMore: true, isFetching: false, onLoad } + const view = renderView(base) + intersect(true) + const retiredCallback = intersectionCallback + const retiredElement = observed + view.rerender({ ...base, isFetching: true, showSentinel: false }) + view.rerender({ ...base, elementRevision: 1 }) + act(() => retiredCallback([{ isIntersecting: false, target: retiredElement } as IntersectionObserverEntry], {} as IntersectionObserver)) + intersect(true) + expect(onLoad).toHaveBeenCalledOnce() + intersect(false) + intersect(true) + expect(onLoad).toHaveBeenCalledTimes(2) + }) + + it("keeps the load locked through detach until its fetch is actually observed", () => { + vi.useFakeTimers() + const onLoad = vi.fn() + const base = { edge, hasMore: true, isFetching: false, onLoad } + const view = renderView(base) + intersect(true) + view.rerender({ ...base, showSentinel: false }) + view.rerender({ ...base, elementRevision: 1 }) + intersect(false) + intersect(true) + const scroll = document.querySelector('[data-testid="scroll"]')! + fireEvent.wheel(scroll, { deltaY: edge === "start" ? -20 : 20 }) + expect(onLoad).toHaveBeenCalledOnce() + view.rerender({ ...base, isFetching: true, elementRevision: 1 }) + view.rerender({ ...base, elementRevision: 1 }) + act(() => vi.advanceTimersByTime(181)) + fireEvent.wheel(scroll, { deltaY: edge === "start" ? -20 : 20 }) + expect(onLoad).toHaveBeenCalledTimes(2) + }) + + it("preserves held keys, touch budget, and wheel idle timer across element replacement", () => { + vi.useFakeTimers() + const onLoad = vi.fn() + const base = { edge, hasMore: true, isFetching: false, onLoad } + const view = renderView(base) + const scroll = document.querySelector('[data-testid="scroll"]')! + const key = edge === "start" ? "PageUp" : "PageDown" + fireEvent.touchStart(scroll, { touches: [{ clientY: 100 }] }) + fireEvent.wheel(scroll, { deltaY: edge === "start" ? -20 : 20 }) + fireEvent.keyDown(scroll, { key }) + intersect(true) + view.rerender({ ...base, isFetching: true, showSentinel: false }) + view.rerender({ ...base, elementRevision: 1 }) + intersect(true) + expect(vi.getTimerCount()).toBe(1) + fireEvent.keyDown(scroll, { key }) + fireEvent.touchMove(scroll, { touches: [{ clientY: edge === "start" ? 130 : 70 }] }) + fireEvent.wheel(scroll, { deltaY: edge === "start" ? -20 : 20 }) + expect(onLoad).toHaveBeenCalledOnce() + fireEvent.keyUp(scroll, { key }) + fireEvent.keyDown(scroll, { key }) + expect(onLoad).toHaveBeenCalledTimes(2) + }) + + it("retires the root timer and gesture listeners when the actual scroller is replaced", () => { + vi.useFakeTimers() + const onLoad = vi.fn() + const base = { edge, hasMore: true, isFetching: false, onLoad } + const view = renderView(base) + const oldRoot = document.querySelector('[data-testid="scroll"]')! + fireEvent.wheel(oldRoot, { deltaY: edge === "start" ? 20 : -20 }) + expect(vi.getTimerCount()).toBe(1) + view.rerender({ ...base, rootRevision: 1 }) + expect(vi.getTimerCount()).toBe(0) + intersect(true) + expect(onLoad).toHaveBeenCalledOnce() + view.rerender({ ...base, rootRevision: 1, isFetching: true }) + view.rerender({ ...base, rootRevision: 1 }) + fireEvent.wheel(oldRoot, { deltaY: edge === "start" ? -20 : 20 }) + expect(onLoad).toHaveBeenCalledOnce() + const current = document.querySelector('[data-testid="scroll"]')! + fireEvent.wheel(current, { deltaY: edge === "start" ? -20 : 20 }) + expect(onLoad).toHaveBeenCalledTimes(2) + }) }) diff --git a/src/web/src/hooks/community/use-virtual-cursor-sentinel.ts b/src/web/src/hooks/community/use-virtual-cursor-sentinel.ts index c7148445e..077100b8a 100644 --- a/src/web/src/hooks/community/use-virtual-cursor-sentinel.ts +++ b/src/web/src/hooks/community/use-virtual-cursor-sentinel.ts @@ -1,10 +1,8 @@ "use client" -import { useEffect, useRef, type RefObject } from "react" -import { useCreateStore } from "@tanstack/react-store" +import { useCallback, useEffect, useLayoutEffect, useRef, type RefObject } from "react" +import { useAtom, useCreateAtom, useCreateStore } from "@tanstack/react-store" -// Wheel events have no lifecycle boundary. A short quiet window separates one -// trackpad/mouse-wheel burst (including momentum) from the next user gesture. const WHEEL_GESTURE_IDLE_MS = 180 export function useVirtualCursorSentinel({ @@ -24,109 +22,71 @@ export function useVirtualCursorSentinel({ onLoad?: () => void edge: "start" | "end" }) { - const sentinelRef = useRef(null) + const [element, setElement] = useAtom(useCreateAtom(null)) + const [root, setRoot] = useAtom(useCreateAtom(null)) + const consumeActiveGesturesRef = useRef<() => void>(() => {}) const protocol = useCreateStore({ inputs: { onBeforeLoad, onLoad, hasMore, isFetching, isSettling }, intersecting: false, intersectionDemanded: false, loadLocked: false, fetchObserved: false }) + const sentinelRef = useCallback((node: HTMLDivElement | null) => { setElement(node) }, [setElement]) - useEffect(() => { + useLayoutEffect(() => { + setRoot(scrollRef.current) protocol.setState((state) => ({ ...state, inputs: { onBeforeLoad, onLoad, hasMore, isFetching, isSettling } })) + const state = protocol.get() if (!hasMore) { - protocol.setState((state) => ({ ...state, loadLocked: false })) - protocol.setState((state) => ({ ...state, fetchObserved: false })) - protocol.setState((state) => ({ ...state, intersectionDemanded: false })) - return - } - if (isFetching) protocol.setState((state) => ({ ...state, fetchObserved: true })) - if ( - protocol.get().loadLocked - && protocol.get().fetchObserved - && !isFetching - && !isSettling - ) { - protocol.setState((state) => ({ ...state, loadLocked: false })) - protocol.setState((state) => ({ ...state, fetchObserved: false })) + protocol.setState((state) => ({ ...state, loadLocked: false, fetchObserved: false, intersectionDemanded: false })) + } else if (isFetching && !state.fetchObserved) { + protocol.setState((state) => ({ ...state, fetchObserved: true })) + } else if (state.loadLocked && state.fetchObserved && !isFetching && !isSettling) { + protocol.setState((state) => ({ ...state, loadLocked: false, fetchObserved: false })) } }) - useEffect(() => { - const element = sentinelRef.current - const root = scrollRef.current - if (!element || !root) return + const nearEdge = useCallback(() => !!root && (edge === "start" + ? root.scrollTop <= 200 + : root.scrollHeight - root.clientHeight - root.scrollTop <= 200), [edge, root]) + const requestPage = useCallback((requireNearEdge: boolean) => { + const state = protocol.get() + if (state.loadLocked || !state.intersecting || (requireNearEdge && !nearEdge()) + || !state.inputs.onLoad || !state.inputs.hasMore || state.inputs.isFetching || state.inputs.isSettling) return + protocol.setState((state) => ({ ...state, loadLocked: true, fetchObserved: false })) + consumeActiveGesturesRef.current() + state.inputs.onBeforeLoad?.() + state.inputs.onLoad() + }, [nearEdge, protocol]) + useEffect(() => { + if (!root) return let touchY: number | null = null let touchDemanded = false let wheelGestureActive = false let wheelDemanded = false let wheelIdleTimer: ReturnType | undefined const heldKeys = new Set() - const finishWheelGesture = () => { wheelGestureActive = false wheelDemanded = false wheelIdleTimer = undefined } - const consumeActiveGestures = () => { + consumeActiveGesturesRef.current = () => { if (touchY !== null) touchDemanded = true if (wheelGestureActive) wheelDemanded = true } - const nearEdge = () => edge === "start" - ? root.scrollTop <= 200 - : root.scrollHeight - root.clientHeight - root.scrollTop <= 200 - const requestPage = (requireNearEdge: boolean) => { - const state = protocol.get().inputs - if ( - protocol.get().loadLocked - || !protocol.get().intersecting - || (requireNearEdge && !nearEdge()) - || !state.onLoad - || !state.hasMore - || state.isFetching - || state.isSettling - ) return - protocol.setState((state) => ({ ...state, loadLocked: true })) - protocol.setState((state) => ({ ...state, fetchObserved: false })) - consumeActiveGestures() - state.onBeforeLoad?.() - state.onLoad() + const onScroll = () => { + if (!nearEdge()) protocol.setState((state) => ({ ...state, intersectionDemanded: false })) } - const observer = new IntersectionObserver((entries) => { - for (const entry of entries) { - protocol.setState((state) => ({ ...state, intersecting: entry.isIntersecting })) - if (!entry.isIntersecting) { - protocol.setState((state) => ({ ...state, intersectionDemanded: false })) - continue - } - if (!protocol.get().intersectionDemanded) { - protocol.setState((state) => ({ ...state, intersectionDemanded: true })) - requestPage(false) - } - break - } - }, { root, rootMargin: "200px" }) const onWheel = (event: WheelEvent) => { - if (!wheelGestureActive) { - wheelGestureActive = true - wheelDemanded = false - } + if (!wheelGestureActive) { wheelGestureActive = true; wheelDemanded = false } if (wheelIdleTimer !== undefined) clearTimeout(wheelIdleTimer) wheelIdleTimer = setTimeout(finishWheelGesture, WHEEL_GESTURE_IDLE_MS) - const towardEdge = (edge === "start" && event.deltaY < 0) - || (edge === "end" && event.deltaY > 0) - if (towardEdge && !wheelDemanded) { - wheelDemanded = true - requestPage(true) - } + const towardEdge = (edge === "start" && event.deltaY < 0) || (edge === "end" && event.deltaY > 0) + if (towardEdge && !wheelDemanded) { wheelDemanded = true; requestPage(true) } } const onKeyDown = (event: KeyboardEvent) => { const active = root.ownerDocument.activeElement - if ( - active - && active !== root.ownerDocument.body - && active !== root - && !root.contains(active) - ) return - const towardStart = ["ArrowUp", "PageUp", "Home"].includes(event.key) - const towardEnd = ["ArrowDown", "PageDown", "End"].includes(event.key) - const towardEdge = (edge === "start" && towardStart) || (edge === "end" && towardEnd) + if (active && active !== root.ownerDocument.body && active !== root && !root.contains(active)) return + const towardEdge = edge === "start" + ? ["ArrowUp", "PageUp", "Home"].includes(event.key) + : ["ArrowDown", "PageDown", "End"].includes(event.key) if (!towardEdge || event.repeat || heldKeys.has(event.key)) return heldKeys.add(event.key) requestPage(true) @@ -142,10 +102,7 @@ export function useVirtualCursorSentinel({ const delta = nextY - touchY touchY = nextY const towardEdge = (edge === "start" && delta > 0) || (edge === "end" && delta < 0) - if (towardEdge && !touchDemanded) { - touchDemanded = true - requestPage(true) - } + if (towardEdge && !touchDemanded) { touchDemanded = true; requestPage(true) } } const onTouchEnd = (event: TouchEvent) => { touchY = event.touches[0]?.clientY ?? null @@ -158,8 +115,7 @@ export function useVirtualCursorSentinel({ if (wheelIdleTimer !== undefined) clearTimeout(wheelIdleTimer) finishWheelGesture() } - - observer.observe(element) + root.addEventListener("scroll", onScroll, { passive: true }) root.addEventListener("wheel", onWheel, { passive: true }) root.ownerDocument.addEventListener("keydown", onKeyDown) root.ownerDocument.addEventListener("keyup", onKeyUp) @@ -169,9 +125,10 @@ export function useVirtualCursorSentinel({ root.addEventListener("touchend", onTouchEnd, { passive: true }) root.addEventListener("touchcancel", onTouchEnd, { passive: true }) return () => { - protocol.setState((state) => ({ ...state, intersecting: false })) - protocol.setState((state) => ({ ...state, intersectionDemanded: false })) + consumeActiveGesturesRef.current = () => {} + protocol.setState((state) => ({ ...state, intersecting: false, intersectionDemanded: false })) if (wheelIdleTimer !== undefined) clearTimeout(wheelIdleTimer) + root.removeEventListener("scroll", onScroll) root.removeEventListener("wheel", onWheel) root.ownerDocument.removeEventListener("keydown", onKeyDown) root.ownerDocument.removeEventListener("keyup", onKeyUp) @@ -180,9 +137,32 @@ export function useVirtualCursorSentinel({ root.removeEventListener("touchmove", onTouchMove) root.removeEventListener("touchend", onTouchEnd) root.removeEventListener("touchcancel", onTouchEnd) + } + }, [edge, nearEdge, protocol, requestPage, root]) + + useEffect(() => { + if (!element || !root || typeof IntersectionObserver === "undefined") return + let active = true + const observer = new IntersectionObserver((entries) => { + if (!active || !root.contains(element)) return + for (const entry of entries) { + if (entry.target !== element) continue + protocol.setState((state) => ({ ...state, intersecting: entry.isIntersecting })) + if (!entry.isIntersecting) { + protocol.setState((state) => ({ ...state, intersectionDemanded: false })) + } else if (!protocol.get().intersectionDemanded) { + protocol.setState((state) => ({ ...state, intersectionDemanded: true })) + requestPage(false) + } + } + }, { root, rootMargin: "200px" }) + observer.observe(element) + return () => { + active = false observer.disconnect() + protocol.setState((state) => ({ ...state, intersecting: false })) } - }, [edge, hasMore, protocol, scrollRef]) + }, [element, protocol, requestPage, root]) return sentinelRef } diff --git a/src/web/src/lib/community/conversation-navigation-proof.dom.test.ts b/src/web/src/lib/community/conversation-navigation-proof.dom.test.ts index 180e781d8..60fdb124e 100644 --- a/src/web/src/lib/community/conversation-navigation-proof.dom.test.ts +++ b/src/web/src/lib/community/conversation-navigation-proof.dom.test.ts @@ -241,7 +241,7 @@ describe("conversation navigation proof", () => { } const renderer = render(createElement(Gate, { accessEpoch: 8 })) - expect(latestGate).toEqual({ required: true, allowed: false }) + expect(latestGate).toMatchObject({ required: true, allowed: false }) expect(restart).toHaveBeenCalledWith(8, 0) let retry!: ReturnType @@ -275,7 +275,7 @@ describe("conversation navigation proof", () => { proof.epoch, ) commitConversationNavigationProof(queryClient, "c1", 9) - const gates: Array<{ required: boolean; allowed: boolean }> = [] + const gates: Array> = [] function Gate() { gates.push(useConversationNavigationGate(queryClient, "viewer", "c1", 9)) @@ -283,8 +283,9 @@ describe("conversation navigation proof", () => { } const renderer = render(createElement(Gate)) - expect(gates).toContainEqual({ required: true, allowed: true }) + expect(gates).toContainEqual(expect.objectContaining({ required: true, allowed: true, target })) expect(getConversationNavigationProof(queryClient)).toBeNull() + expect(gates.at(-1)?.target).toBeNull() await act(async () => renderer.unmount()) }) @@ -298,4 +299,25 @@ describe("conversation navigation proof", () => { expect(renderToString(createElement(Gate))).toContain("false:true") }) + it("keeps a message timeout failed until manual retry and refuses retry after retirement", async () => { + vi.useFakeTimers() + const queryClient = createClient() + const proof = beginConversationNavigationProof(queryClient, target, 1) + const restart = vi.fn() + registerConversationNavigationRecovery(queryClient, proof.epoch, restart) + failConversationNavigationProof(queryClient, proof.epoch, 1, false, true) + let gate!: ReturnType + function Gate() { gate = useConversationNavigationGate(queryClient, "viewer", "c1", 1); return null } + const view = render(createElement(Gate)) + expect(gate).toMatchObject({ required: true, allowed: false, failed: true }) + await act(async () => { vi.advanceTimersByTime(60_000) }) + expect(restart).not.toHaveBeenCalled() + act(() => gate.retry()) + expect(restart).toHaveBeenCalledExactlyOnceWith(1, 1) + cancelActiveConversationNavigationProof(queryClient) + act(() => gate.retry()) + expect(restart).toHaveBeenCalledOnce() + view.unmount() + }) + }) diff --git a/src/web/src/lib/community/conversation-navigation-proof.ts b/src/web/src/lib/community/conversation-navigation-proof.ts index e9915157c..6cd2f8080 100644 --- a/src/web/src/lib/community/conversation-navigation-proof.ts +++ b/src/web/src/lib/community/conversation-navigation-proof.ts @@ -30,6 +30,7 @@ export type ConversationNavigationProof = { recoveryAttempt: number target: ConversationNavigationTarget status: ProofStatus + manualRetry?: boolean } type ConversationNavigationRecovery = ( @@ -210,6 +211,7 @@ export function failConversationNavigationProof( epoch: number, accessEpoch: number, definitive: boolean, + manualRetry = false, ) { const store = getStore(queryClient) const current = store.get() @@ -231,7 +233,7 @@ export function failConversationNavigationProof( activeEpoch: state.nextEpoch + 1, recovery: null, } : {}), - proof: { ...proof, status: definitive ? "denied" : "failed" }, + proof: { ...proof, status: definitive ? "denied" : "failed", manualRetry }, })) } @@ -292,7 +294,7 @@ export function useConversationNavigationGate( viewerId: string, channelId: string, accessEpoch: number, -): { required: boolean; allowed: boolean } { +): { required: boolean; allowed: boolean; failed: boolean; retry: () => void; target: ConversationNavigationTarget | null } { const store = getStore(queryClient) const proof = useSelector(store, (state) => state.proof) const matching = proof?.target.viewerId === viewerId @@ -309,7 +311,7 @@ export function useConversationNavigationGate( recoverConversationNavigationProof(queryClient, proof.epoch, accessEpoch) return } - if (proof.status !== "failed") return + if (proof.status !== "failed" || proof.manualRetry) return const delay = Math.min(250 * (2 ** proof.recoveryAttempt), 5_000) const timeout = setTimeout(() => { recoverConversationNavigationProof(queryClient, proof.epoch, accessEpoch) @@ -327,5 +329,10 @@ export function useConversationNavigationGate( consumeConversationNavigationProof(queryClient, proof.epoch) }, [accessEpoch, matching, proof, queryClient]) - return { required, allowed } + return { required, allowed, + target: required && isCurrentConversationNavigation(queryClient, proof.epoch, accessEpoch) + ? proof.target : null, + failed: required && proof.accessEpoch === accessEpoch && proof.status === "failed" && proof.manualRetry === true, + retry: () => { if (proof && matching) recoverConversationNavigationProof(queryClient, proof.epoch, accessEpoch) }, + } } diff --git a/src/web/src/lib/community/conversation-navigation-warmup-owner.dom.test.tsx b/src/web/src/lib/community/conversation-navigation-warmup-owner.dom.test.tsx index 1b26a1fc6..90f522f32 100644 --- a/src/web/src/lib/community/conversation-navigation-warmup-owner.dom.test.tsx +++ b/src/web/src/lib/community/conversation-navigation-warmup-owner.dom.test.tsx @@ -1,5 +1,5 @@ import React, { StrictMode, useLayoutEffect } from "react" -import { QueryClient } from "@tanstack/react-query" +import { QueryClient, useInfiniteQuery } from "@tanstack/react-query" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { act, render, waitFor } from "@/test/react-dom-harness" import { CommunityTestProvider } from "@/test/community-owner-fixture" @@ -8,7 +8,9 @@ import { createQueryClient } from "@/lib/query-client" import { communityKeys } from "@/lib/query-keys" import { apiFetch } from "@/lib/api/client" import { startConversationNavigationWarmup } from "./conversation-navigation-warmup" -import { getConversationNavigationProof, useConversationNavigationGate } from "./conversation-navigation-proof" +import { getConversationNavigationProof, recoverConversationNavigationProof, useConversationNavigationGate } from "./conversation-navigation-proof" +import { channelMessagesQueryFn, dmMessagesQueryFn } from "@/hooks/community/use-messages" +import type { MessagesPageParam } from "@/lib/community/models/message" vi.mock("@/lib/api/client", async (load) => { const actual = await load() @@ -65,6 +67,174 @@ function response(status: number) { } describe("warmup original owner and complete intent resources", () => { + it.each(["channel", "thread", "forum", "dm"].flatMap((kind) => + ["anchor", "older"].map((mode) => ({ kind, mode })), + ))("reads the current click after retained $mode pages on $kind", async ({ kind, mode }) => { + const owner = createClient() + await owner.registry.preload() + const navigation = { ...target, scopeKind: kind === "dm" ? "dm" as const : "channel" as const, + expectedSurfaceKind: kind as "channel" | "thread" | "forum" | "dm" } + const key = kind === "dm" ? communityKeys.dmMessages("d1") : communityKeys.channelMessages("d1") + owner.client.setQueryDefaults(key, { retry: false }) + const oldParam: MessagesPageParam = mode === "anchor" + ? { mode: "anchor", anchor: "old-anchor" } : { mode: "older", cursor: "old-cursor" } + const retained = { pages: [{ messages: [], hasMore: true }, { messages: [], hasMore: false }], + pageParams: [oldParam, { mode: "older", cursor: "old-second-cursor" } as MessagesPageParam] } + owner.client.setQueryData(key, retained) + const requests: Array<{ path: string; resolve: (value: Response) => void }> = [] + vi.stubGlobal("fetch", vi.fn((path: string) => { + if (!path.includes("/messages")) return Promise.resolve(response(200)) + return new Promise((resolve) => requests.push({ path, resolve })) + })) + for (const anchorMessageId of ["current-marked-message", undefined]) { + const previousCount = requests.length + const epoch = startConversationNavigationWarmup(owner.client, { ...navigation, anchorMessageId }, 0) + await waitFor(() => expect(requests).toHaveLength(previousCount + 1)) + const request = requests[previousCount]! + const url = new URL(request.path, "https://alook.test") + expect(url.searchParams.get("anchor")).toBe(anchorMessageId ?? null) + expect(url.searchParams.has("cursor")).toBe(false) + expect(owner.client.getQueryData<{ pages: unknown[] }>(key)?.pages).toHaveLength(previousCount === 0 ? 2 : 1) + await act(async () => request.resolve(new Response(JSON.stringify({ + messages: [], hasMore: true, surfaceReceipt: { channelId: "d1", surfaceKind: kind }, + }), { status: 200, headers: { "Content-Type": "application/json" } }))) + await waitFor(() => expect(getConversationNavigationProof(owner.client)).toMatchObject({ epoch, status: kind === "forum" ? "forum" : "proven" })) + await waitFor(() => expect(owner.client.getQueryCache().find({ queryKey: key, exact: true })!.getObserversCount()).toBe(0)) + expect(requests).toHaveLength(previousCount + 1) + expect(owner.client.getQueryData(key)).toMatchObject({ pages: [{ messages: [] }], + pageParams: [anchorMessageId ? { mode: "anchor", anchor: anchorMessageId } : { mode: "newest" }] }) + } + }) + + it.each(["channel", "thread", "forum", "dm"] as const)("keeps the click-owned %s transport alive after its prior visible body unmounts", async (surfaceKind) => { + const owner = createClient() + await owner.registry.preload() + const navigation = { ...target, anchorMessageId: "marked-message", + scopeKind: surfaceKind === "dm" ? "dm" as const : "channel" as const, expectedSurfaceKind: surfaceKind } + const key = surfaceKind === "dm" ? communityKeys.dmMessages("d1") : communityKeys.channelMessages("d1") + const messageRequests: Array<{ signal: AbortSignal; resolve: (value: Response) => void; path: string }> = [] + vi.stubGlobal("fetch", vi.fn((path: string, options: RequestInit) => { + if (!path.includes("/messages")) return Promise.resolve(response(200)) + return new Promise((resolve) => messageRequests.push({ signal: options.signal!, resolve, path })) + })) + const bodyQueryFn = surfaceKind === "dm" + ? dmMessagesQueryFn("d1", { queryClient: owner.client }) + : channelMessagesQueryFn("d1", null, { queryClient: owner.client }) + function Body() { + const query = useInfiniteQuery({ queryKey: key, queryFn: bodyQueryFn, + initialPageParam: { mode: "newest" } as MessagesPageParam, getNextPageParam: () => undefined, + staleTime: Infinity, retry: false }) + return

{query.isSuccess ? "Current target body" : "Original pending body"}

+ } + function Surface() { + const gate = useConversationNavigationGate(owner.client, "viewer", "d1", 0) + return gate.allowed ? :

{gate.failed ? "Manual Retry" : "Warming target"}

+ } + const mounted = render() + await waitFor(() => expect(messageRequests).toHaveLength(1)) + act(() => { startConversationNavigationWarmup(owner.client, navigation, 0) }) + await waitFor(() => expect(messageRequests).toHaveLength(2)) + expect(mounted.getByText("Warming target")).toBeTruthy() + const resource = owner.client.getQueryCache().find({ queryKey: key, exact: true })! + await waitFor(() => expect(resource.getObserversCount()).toBe(1)) + expect(resource.isActive()).toBe(false) + expect(messageRequests[0]!.signal.aborted).toBe(true) + expect(messageRequests[1]!.signal.aborted).toBe(false) + expect(messageRequests[1]!.path).toContain("anchor=marked-message") + await act(async () => messageRequests[1]!.resolve(new Response(JSON.stringify({ + messages: [], hasMore: false, surfaceReceipt: { channelId: "d1", surfaceKind }, + }), { status: 200, headers: { "Content-Type": "application/json" } }))) + await waitFor(() => expect(mounted.getByText("Current target body")).toBeTruthy()) + await waitFor(() => expect(getConversationNavigationProof(owner.client)).toBeNull()) + expect(resource.getObserversCount()).toBe(1) + expect(resource.isActive()).toBe(true) + expect(messageRequests).toHaveLength(2) + mounted.unmount() + expect(resource.getObserversCount()).toBe(0) + }) + + it.each(["cancel", "failure", "owner"] as const)("releases the original warmup message lease after %s settlement", async (settlement) => { + const owner = createClient() + await owner.registry.preload() + owner.client.setQueryDefaults(communityKeys.dmMessages("d1"), { retry: false }) + let finish!: (value: Response) => void + let messageSignal!: AbortSignal + vi.stubGlobal("fetch", vi.fn((path: string, options: RequestInit) => { + if (!path.includes("/messages")) return Promise.resolve(response(200)) + messageSignal = options.signal! + return new Promise((resolve) => { finish = resolve }) + })) + const epoch = startConversationNavigationWarmup(owner.client, target, 0) + await waitFor(() => expect(messageSignal).toBeDefined()) + const resource = owner.client.getQueryCache().find({ queryKey: communityKeys.dmMessages("d1"), exact: true })! + expect(resource.getObserversCount()).toBe(1) + if (settlement === "cancel") { + const { cancelConversationNavigationProof } = await import("./conversation-navigation-proof") + cancelConversationNavigationProof(owner.client, epoch) + } else if (settlement === "owner") owner.unregister() + else finish(new Response(JSON.stringify({ error: "unavailable" }), { status: 503, headers: { "Content-Type": "application/json" } })) + if (settlement === "failure") { + await waitFor(() => expect(getConversationNavigationProof(owner.client)).toMatchObject({ status: "failed", manualRetry: true }), { timeout: 5_000 }) + } + await waitFor(() => expect(resource.getObserversCount()).toBe(0)) + if (settlement !== "failure") expect(messageSignal.aborted).toBe(true) + }) + + it("keeps a repeated same-target click alive after the old lease and late unauthorized response settle", async () => { + const owner = createClient() + await owner.registry.preload() + const messageRequests: Array<{ signal: AbortSignal; resolve: (value: Response) => void }> = [] + vi.stubGlobal("fetch", vi.fn((path: string, options: RequestInit) => { + if (!path.includes("/messages")) return Promise.resolve(response(200)) + return new Promise((resolve) => messageRequests.push({ signal: options.signal!, resolve })) + })) + startConversationNavigationWarmup(owner.client, { ...target, anchorMessageId: "first" }, 0) + await waitFor(() => expect(messageRequests).toHaveLength(1)) + const epoch = startConversationNavigationWarmup(owner.client, { ...target, anchorMessageId: "second" }, 0) + await waitFor(() => expect(messageRequests).toHaveLength(2)) + const resource = owner.client.getQueryCache().find({ queryKey: communityKeys.dmMessages("d1"), exact: true })! + await waitFor(() => expect(resource.getObserversCount()).toBe(1)) + expect(messageRequests[0]!.signal.aborted).toBe(true) + expect(messageRequests[1]!.signal.aborted).toBe(false) + await act(async () => messageRequests[0]!.resolve(response(401))) + expect(assign).not.toHaveBeenCalled() + expect(getConversationNavigationProof(owner.client)).toMatchObject({ epoch, status: "warming", target: { anchorMessageId: "second" } }) + await act(async () => messageRequests[1]!.resolve(new Response(JSON.stringify({ + messages: [], hasMore: false, surfaceReceipt: { channelId: "d1", surfaceKind: "dm" }, + }), { status: 200, headers: { "Content-Type": "application/json" } }))) + await waitFor(() => expect(getConversationNavigationProof(owner.client)).toMatchObject({ epoch, status: "proven" })) + await waitFor(() => expect(resource.getObserversCount()).toBe(0)) + expect(messageRequests).toHaveLength(2) + }) + + it.each(["channel", "thread", "forum", "dm"] as const)("ends persistent %s 503 with manual Retry after finite Query retries", async (surfaceKind) => { + const owner = createClient() + await owner.registry.preload() + const defaults = owner.client.getDefaultOptions() + owner.client.setDefaultOptions({ ...defaults, queries: { ...defaults.queries, retry: 1, retryDelay: 0 } }) + let failing = true + let requests = 0 + vi.stubGlobal("fetch", vi.fn((path: string) => { + if (path.includes("/messages")) { + requests++ + return Promise.resolve(new Response(JSON.stringify(failing ? { error: "unavailable" } : { messages: [], hasMore: false, surfaceReceipt: { channelId: "d1", surfaceKind } }), { status: failing ? 503 : 200, headers: { "Content-Type": "application/json" } })) + } + return Promise.resolve(response(200)) + })) + const navigation = { ...target, scopeKind: surfaceKind === "dm" ? "dm" as const : "channel" as const, expectedSurfaceKind: surfaceKind } + const epoch = startConversationNavigationWarmup(owner.client, navigation, 0) + const rendered = render() + await waitFor(() => expect(getConversationNavigationProof(owner.client)).toMatchObject({ status: "failed", manualRetry: true })) + expect(requests).toBe(2) + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 350)) }) + expect(requests).toBe(2) + failing = false + act(() => { expect(recoverConversationNavigationProof(owner.client, epoch, 0)).toBe(true) }) + await waitFor(() => expect(getConversationNavigationProof(owner.client)).toBeNull()) + expect(requests).toBe(3) + rendered.unmount() + }) + it.each([401, 403, 200].flatMap((status) => [false, true].map((consume) => ({ status, consume }))))("retired owner late $status stays silent, consumed=$consume", async ({ status, consume }) => { const owner = createClient() startConversationNavigationWarmup(owner.client, target, 0) @@ -88,6 +258,8 @@ describe("warmup original owner and complete intent resources", () => { it.each([401, 403, 200])("current owner late %i retains ordinary behavior", async (status) => { const owner = createClient() + owner.registry.authenticationView.setState((state) => ({ ...state, active: true })) + const retireDisk = vi.spyOn(owner.registry, "retireDisk").mockResolvedValue(undefined) startConversationNavigationWarmup(owner.client, target, 0) await waitFor(() => expect(releases).toHaveLength(1)) await waitFor(() => expect(getConversationNavigationProof(owner.client)?.status).toBe("proven")) @@ -97,12 +269,19 @@ describe("warmup original owner and complete intent resources", () => { const settled = request.catch((error: unknown) => error) releases[0](response(status)) const result = await settled - await waitFor(() => expect(owner.client.getQueryState(readKey)?.fetchStatus).toBe("idle")) expect(vi.mocked(apiFetch).mock.calls.filter(([path]) => path.endsWith("/read-state"))).toHaveLength(1) - if (status !== 200) { + if (status === 401) { + expect(result).toMatchObject({ status }) + expect(owner.client.getQueryState(readKey)).toBeUndefined() + expect(owner.registry.runtime.lifecycle.get().active).toBe(false) + expect(retireDisk).toHaveBeenCalledOnce() + expect(assign).toHaveBeenCalledOnce() + } else if (status !== 200) { + await waitFor(() => expect(owner.client.getQueryState(readKey)?.fetchStatus).toBe("idle")) expect(result).toMatchObject({ status }) expect(owner.client.getQueryState(readKey)?.error).toMatchObject({ status }) - expect(assign).toHaveBeenCalledTimes(status === 401 ? 1 : 0) + expect(retireDisk).not.toHaveBeenCalled() + expect(assign).not.toHaveBeenCalled() } else { await waitFor(() => expect(owner.client.getQueryData(readKey)).toMatchObject({ lastReadSeq: 5 })) expect(assign).not.toHaveBeenCalled() diff --git a/src/web/src/lib/community/conversation-navigation-warmup.test.ts b/src/web/src/lib/community/conversation-navigation-warmup.test.ts index cfddda2bd..b3b80103a 100644 --- a/src/web/src/lib/community/conversation-navigation-warmup.test.ts +++ b/src/web/src/lib/community/conversation-navigation-warmup.test.ts @@ -5,6 +5,7 @@ import { ApiError } from "@/lib/errors" import { communityKeys } from "@/lib/query-keys" import { getConversationNavigationProof, + recordConversationNavigationReceipt, recoverConversationNavigationProof, } from "./conversation-navigation-proof" import { startConversationNavigationWarmup } from "./conversation-navigation-warmup" @@ -128,6 +129,42 @@ describe("conversation navigation warmup", () => { mocks.apiFetch.mockReset() }) + it.each(["channel", "thread", "forum", "dm"] as const)("starts a current %s receipt transport instead of joining an older-access target read", async (surfaceKind) => { + const queryClient = createClient() + const navigation = { ...target("active"), scopeKind: surfaceKind === "dm" ? "dm" as const : "channel" as const, expectedSurfaceKind: surfaceKind } + const key = surfaceKind === "dm" ? communityKeys.dmMessages("active") : communityKeys.channelMessages("active") + let release!: (value: Page) => void + let originalSignal!: AbortSignal + const existing = queryClient.fetchInfiniteQuery({ queryKey: key, initialPageParam: { mode: "newest" }, queryFn: ({ signal }) => { + originalSignal = signal + return new Promise((resolve) => { release = resolve }).then((page) => { + recordConversationNavigationReceipt(queryClient, { channelId: "active", surfaceKind }, 4) + return page + }) + } }).catch((error: unknown) => error) + startConversationNavigationWarmup(queryClient, navigation, 5) + release({ messages: [], hasMore: false }) + await existing + expect(getConversationNavigationProof(queryClient)?.status).toBe("warming") + await vi.waitFor(() => expect(mocks.requests).toHaveLength(1)) + expect(originalSignal.aborted).toBe(true) + mocks.requests[0]!.receipt({ channelId: "active", surfaceKind }) + mocks.requests[0]!.resolve({ messages: [], hasMore: false }) + await vi.waitFor(() => expect(getConversationNavigationProof(queryClient)?.status).toBe(surfaceKind === "forum" ? "forum" : "proven")) + }) + + it.each(["channel", "thread", "forum", "dm"] as const)("makes a completed %s read without its receipt manually retryable", async (surfaceKind) => { + const queryClient = createClient() + const navigation = { ...target("missing-receipt"), scopeKind: surfaceKind === "dm" ? "dm" as const : "channel" as const, expectedSurfaceKind: surfaceKind } + const epoch = startConversationNavigationWarmup(queryClient, navigation, 5) + mocks.requests[0]!.resolve({ messages: [], hasMore: false }) + await vi.waitFor(() => expect(getConversationNavigationProof(queryClient)).toMatchObject({ status: "failed", manualRetry: true })) + expect(recoverConversationNavigationProof(queryClient, epoch, 5)).toBe(true) + mocks.requests[1]!.receipt({ channelId: navigation.channelId, surfaceKind }) + mocks.requests[1]!.resolve({ messages: [], hasMore: false }) + await vi.waitFor(() => expect(getConversationNavigationProof(queryClient)?.status).toBe(surfaceKind === "forum" ? "forum" : "proven")) + }) + it("starts canonical work in parallel and prevents superseded A from seeding", async () => { const queryClient = createClient() startConversationNavigationWarmup(queryClient, target("a"), 4) @@ -157,7 +194,9 @@ describe("conversation navigation warmup", () => { it("clears target caches and overlays on definitive denial", async () => { const queryClient = createClient() - queryClient.setQueryData(communityKeys.channelMessages("denied"), { stale: true }) + queryClient.setQueryData(communityKeys.channelMessages("denied"), { + pages: [{ messages: [], hasMore: false }], pageParams: [{ mode: "newest" }], + }) queryClient.setQueryData(communityKeys.channelReadStateSnapshot("denied"), { stale: true }) queryClient.setQueryData(communityKeys.channelMeta("s1", "denied"), { stale: true }) startConversationNavigationWarmup(queryClient, target("denied"), 9) @@ -195,7 +234,9 @@ describe("conversation navigation warmup", () => { scopeKind: "dm" as const, expectedSurfaceKind: "dm" as const, } - queryClient.setQueryData(communityKeys.dmMessages("d1"), { stale: true }) + queryClient.setQueryData(communityKeys.dmMessages("d1"), { + pages: [{ messages: [], hasMore: false }], pageParams: [{ mode: "newest" }], + }) queryClient.setQueryData(communityKeys.dmReadStateSnapshot("d1"), { stale: true }) queryClient.setQueryData(communityKeys.channelMeta(null, "d1"), "present") startConversationNavigationWarmup(queryClient, dmTarget, 2) diff --git a/src/web/src/lib/community/conversation-navigation-warmup.ts b/src/web/src/lib/community/conversation-navigation-warmup.ts index e0ff6b9ec..82aa96733 100644 --- a/src/web/src/lib/community/conversation-navigation-warmup.ts +++ b/src/web/src/lib/community/conversation-navigation-warmup.ts @@ -1,31 +1,26 @@ "use client" -import { getCommunityDbRegistry } from "@/lib/community-db/collections" import { getCommunityRuntime } from "@/stores/community/runtime" -import { QueryObserver, type QueryClient } from "@tanstack/react-query" -import { apiFetch } from "@/lib/api/client" +import { InfiniteQueryObserver, QueryObserver, type InfiniteData, type QueryClient } from "@tanstack/react-query" +import { conversationReadRetryPolicy } from "./conversation-read" import { ApiError } from "@/lib/errors" import { communityKeys } from "@/lib/query-keys" -import type { MessagesPageParam } from "@/lib/community/models/message" +import type { MessagesPage, MessagesPageParam } from "@/lib/community/models/message" import { channelMessagesQueryFn, dmMessagesQueryFn } from "@/hooks/community/use-messages" +import { channelReadStateSnapshotQueryFn } from "@/hooks/community/use-channel-read-state" import { serverProjectedQueryFn } from "@/hooks/community/use-servers" import { beginConversationNavigationProof, commitConversationNavigationProof, failConversationNavigationProof, + getConversationNavigationProof, isCurrentConversationNavigation, recordConversationNavigationReceipt, registerConversationNavigationRecovery, type ConversationNavigationTarget, } from "./conversation-navigation-proof" -type ReadSnapshot = { - lastReadMessageId: string | null - lastReadAt: string | null - lastReadSeq: number -} - function isDefinitiveAccessFailure(error: unknown): boolean { return error instanceof ApiError && (error.status === 403 || error.status === 404) } @@ -86,47 +81,62 @@ export function startConversationNavigationWarmup( }, }) - void queryClient.fetchInfiniteQuery({ + void queryClient.cancelQueries({ queryKey: messagesKey, exact: true }) + queryClient.setQueryData>(messagesKey, (current) => current + ? { ...current, pageParams: [pageParam, ...current.pageParams.slice(1)] } + : current) + const messagesOptions = { queryKey: messagesKey, queryFn, initialPageParam: pageParam, + pages: 1, + getNextPageParam: () => undefined, + retry: conversationReadRetryPolicy(queryClient.defaultQueryOptions({ queryKey: messagesKey }).retry), + networkMode: "always" as const, // A persisted/memory-warm page is only a hint. Force this click-owned // query through the canonical door so a fresh receipt is always emitted. staleTime: 0, + } + const messagesObserver = new InfiniteQueryObserver(queryClient, { + ...messagesOptions, enabled: false, }) + const releaseMessages = messagesObserver.subscribe(() => undefined) + void queryClient.fetchInfiniteQuery(messagesOptions) .then(() => { if (!isCurrentConversationNavigation(queryClient, epoch, accessEpoch)) return commitConversationNavigationProof(queryClient, target.channelId, accessEpoch) + if (getConversationNavigationProof(queryClient)?.status === "warming") { + failConversationNavigationProof(queryClient, epoch, accessEpoch, false, true) + } }) .catch((error) => { if (signal.aborted || !isCurrentConversationNavigation(queryClient, epoch, accessEpoch)) return const definitive = isDefinitiveAccessFailure(error) if (definitive) clearDeniedTarget(queryClient, target) - failConversationNavigationProof(queryClient, epoch, accessEpoch, definitive) + failConversationNavigationProof(queryClient, epoch, accessEpoch, definitive, true) }) + .finally(releaseMessages) const readKey = target.scopeKind === "dm" ? communityKeys.dmReadStateSnapshot(target.channelId) : communityKeys.channelReadStateSnapshot(target.channelId) - const registry = getCommunityDbRegistry(queryClient)! void queryClient.fetchQuery({ queryKey: readKey, staleTime: 0, retry: false, - queryFn: async ({ signal: querySignal }) => { - await registry.ready - return apiFetch(`/api/community/channels/${target.channelId}/read-state`, { - signal: querySignal, - assertActive: () => { - if (!isCurrentConversationNavigation(queryClient, epoch, accessEpoch)) throw new DOMException("Retired conversation warmup", "AbortError") - }, - }) - }, + networkMode: "always", + queryFn: channelReadStateSnapshotQueryFn(target.channelId, target.scopeKind, { + waitForRegistryReady: true, + assertNavigationCurrent: () => { + if (!isCurrentConversationNavigation(queryClient, epoch, accessEpoch)) throw new DOMException("Retired conversation warmup", "AbortError") + }, + }), }).catch(() => undefined) if (target.serverId) { const serverId = target.serverId - const options = { queryKey: communityKeys.server(serverId), queryFn: ({ signal }: { signal: AbortSignal }) => serverProjectedQueryFn(queryClient, serverId, signal)(), staleTime: Infinity } + const queryKey = communityKeys.server(serverId) + const options = { queryKey, queryFn: ({ signal }: { signal: AbortSignal }) => serverProjectedQueryFn(queryClient, serverId, signal)(), staleTime: Infinity, networkMode: "always" as const, retry: conversationReadRetryPolicy(queryClient.defaultQueryOptions({ queryKey }).retry) } const observer = new QueryObserver(queryClient, { ...options, enabled: false }) const release = observer.subscribe(() => undefined) void queryClient.fetchQuery(options).finally(release).catch(() => undefined) diff --git a/src/web/src/lib/community/conversation-read.test.ts b/src/web/src/lib/community/conversation-read.test.ts new file mode 100644 index 000000000..3c2f2abf8 --- /dev/null +++ b/src/web/src/lib/community/conversation-read.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { CONVERSATION_READ_TIMEOUT_MS, ConversationReadTimeoutError, retryConversationRead, withConversationReadDeadline } from "./conversation-read" + +beforeEach(() => vi.useFakeTimers()) +afterEach(() => vi.useRealTimers()) + +describe("conversation read deadline", () => { + it("rejects a transport that ignores cancellation and aborts its original publication signal", async () => { + let resolve!: (value: string) => void + let signal!: AbortSignal + const read = withConversationReadDeadline(undefined, (current) => { + signal = current + return new Promise((done) => { resolve = done }) + }) + const result = expect(read).rejects.toBeInstanceOf(ConversationReadTimeoutError) + await vi.advanceTimersByTimeAsync(CONVERSATION_READ_TIMEOUT_MS) + await result + expect(signal.aborted).toBe(true) + expect(retryConversationRead(0, signal.reason)).toBe(false) + resolve("late") + await Promise.resolve() + expect(vi.getTimerCount()).toBe(0) + }) + + it("keeps a successful empty result distinct from a timeout and disposes the timer", async () => { + await expect(withConversationReadDeadline(undefined, async () => [])).resolves.toEqual([]) + expect(vi.getTimerCount()).toBe(0) + }) + + it("follows the original Query cancellation without starting a replacement read", async () => { + const controller = new AbortController() + const transport = vi.fn((_signal: AbortSignal) => new Promise(() => {})) + const read = withConversationReadDeadline(controller.signal, transport) + const result = expect(read).rejects.toMatchObject({ name: "AbortError" }) + await Promise.resolve() + controller.abort(new DOMException("Account retired", "AbortError")) + await result + expect(transport).toHaveBeenCalledOnce() + expect(transport.mock.calls[0][0].aborted).toBe(true) + expect(vi.getTimerCount()).toBe(0) + }) + + it("does not start a read whose original owner is already retired", async () => { + const controller = new AbortController() + controller.abort() + const transport = vi.fn(async () => "unexpected") + await expect(withConversationReadDeadline(controller.signal, transport)).rejects.toMatchObject({ name: "AbortError" }) + expect(transport).not.toHaveBeenCalled() + }) + + it("retains one ordinary transient retry and refuses auth, access and timeout retries", () => { + expect(retryConversationRead(0, new Error("transient"))).toBe(true) + expect(retryConversationRead(1, new Error("transient"))).toBe(false) + for (const status of [401, 403, 404]) expect(retryConversationRead(0, Object.assign(new Error("denied"), { status }))).toBe(false) + expect(retryConversationRead(0, new ConversationReadTimeoutError())).toBe(false) + }) + it("keeps cancellation and deadline behavior when AbortSignal.any is unavailable", async () => { + const descriptor = Object.getOwnPropertyDescriptor(AbortSignal, "any") + Object.defineProperty(AbortSignal, "any", { configurable: true, value: undefined }) + try { + const controller = new AbortController() + let child!: AbortSignal + const read = withConversationReadDeadline(controller.signal, (signal) => { child = signal; return new Promise(() => {}) }) + const result = expect(read).rejects.toMatchObject({ name: "AbortError" }) + controller.abort() + await result + expect(child.aborted).toBe(true) + const timed = withConversationReadDeadline(undefined, () => new Promise(() => {})) + const timeout = expect(timed).rejects.toBeInstanceOf(ConversationReadTimeoutError) + await vi.advanceTimersByTimeAsync(CONVERSATION_READ_TIMEOUT_MS) + await timeout + } finally { + if (descriptor) Object.defineProperty(AbortSignal, "any", descriptor) + else Reflect.deleteProperty(AbortSignal, "any") + } + }) + +}) diff --git a/src/web/src/lib/community/conversation-read.ts b/src/web/src/lib/community/conversation-read.ts new file mode 100644 index 000000000..15f3578c4 --- /dev/null +++ b/src/web/src/lib/community/conversation-read.ts @@ -0,0 +1,58 @@ +export const CONVERSATION_READ_TIMEOUT_MS = 15_000 + +export class ConversationReadTimeoutError extends Error { + constructor() { + super("Conversation read timed out") + this.name = "ConversationReadTimeoutError" + } +} + +export function isConversationAccessError(error: unknown): boolean { + return typeof error === "object" && error !== null && "status" in error + && [401, 403, 404].includes(Number(error.status)) +} + +export function retryConversationRead(failureCount: number, error: Error): boolean { + return !(error instanceof ConversationReadTimeoutError) + && error.name !== "AbortError" + && !isConversationAccessError(error) + && failureCount < 1 +} + +export async function withConversationReadDeadline( + signal: AbortSignal | undefined, + read: (signal: AbortSignal) => Promise, +): Promise { + const controller = new AbortController() + const readSignal = signal && typeof AbortSignal.any === "function" + ? AbortSignal.any([signal, controller.signal]) : controller.signal + let rejectAborted!: (error: unknown) => void + const aborted = new Promise((_, reject) => { rejectAborted = reject }) + const cancel = (reason: unknown) => { + controller.abort(reason) + rejectAborted(reason) + } + const onAbort = () => cancel(signal?.reason ?? new DOMException("Retired conversation read", "AbortError")) + signal?.addEventListener("abort", onAbort, { once: true }) + const timer = setTimeout(() => cancel(new ConversationReadTimeoutError()), CONVERSATION_READ_TIMEOUT_MS) + try { + if (signal?.aborted) onAbort() + if (readSignal.aborted) return await aborted + const result = await Promise.race([read(readSignal), aborted]) + controller.signal.throwIfAborted() + return result + } finally { + clearTimeout(timer) + signal?.removeEventListener("abort", onAbort) + } +} + +export function conversationReadRetryPolicy( + policy: boolean | number | ((failureCount: number, error: Error) => boolean) = 3, +) { + return (failureCount: number, error: Error): boolean => { + if (error instanceof ConversationReadTimeoutError || error.name === "AbortError" || isConversationAccessError(error)) return false + if (typeof policy === "function") return policy(failureCount, error) + return policy === true || (policy !== false && failureCount < policy) + } +} diff --git a/src/web/src/lib/community/message-list-items.test.ts b/src/web/src/lib/community/message-list-items.test.ts index eb90cd1b9..447d7d1bb 100644 --- a/src/web/src/lib/community/message-list-items.test.ts +++ b/src/web/src/lib/community/message-list-items.test.ts @@ -13,84 +13,53 @@ function msg(overrides: Partial & { id: string }): Msg { } describe("flattenMessageItems", () => { - it("emits one 'message' item per message, plus a date-divider before the first message of a new day", () => { + it("has exactly one stable virtual row per actual message", () => { const items = flattenMessageItems([msg({ id: "m1" })], undefined) - expect(items.map((i) => i.kind)).toEqual(["date-divider", "message"]) + expect(items).toHaveLength(1) + expect(items[0]).toMatchObject({ kind: "message", key: "msg:id:m1", dateLabel: expect.any(String) }) }) - it("does not emit a date-divider between two messages on the same day", () => { - const items = flattenMessageItems( - [ - msg({ id: "m1", createdAt: "2026-01-01T10:00:00.000Z" }), - msg({ id: "m2", createdAt: "2026-01-01T10:01:00.000Z" }), - ], - undefined, - ) - expect(items.map((i) => i.kind)).toEqual(["date-divider", "message", "message"]) + it("adds date metadata only at the first message of each day", () => { + const items = flattenMessageItems([ + msg({ id: "m1" }), + msg({ id: "m2", createdAt: "2026-01-01T10:01:00.000Z" }), + msg({ id: "m3", createdAt: "2026-01-02T12:00:00.000Z" }), + ], undefined) + expect(items.map(item => !!item.dateLabel)).toEqual([true, false, true]) + expect(items.map(item => item.m.id)).toEqual(["m1", "m2", "m3"]) }) - it("emits a second date-divider when the day changes", () => { - const items = flattenMessageItems( - [ - msg({ id: "m1", createdAt: "2026-01-01T10:00:00.000Z" }), - msg({ id: "m2", createdAt: "2026-01-02T10:00:00.000Z" }), - ], - undefined, - ) - expect(items.map((i) => i.kind)).toEqual(["date-divider", "message", "date-divider", "message"]) + it("puts same-day New metadata on its owning message without a second key", () => { + const messages = [msg({ id: "m1" }), msg({ id: "m2" })] + const items = flattenMessageItems(messages, "m2") + expect(items).toHaveLength(2) + expect(items[1]).toMatchObject({ newDivider: true, m: { id: "m2" } }) + expect(items[1].dateLabel).toBeUndefined() + expect(items.map(item => item.key)).toEqual(flattenMessageItems(messages, undefined).map(item => item.key)) }) - it("emits a new-divider item immediately before the message matching newDividerBefore", () => { - const items = flattenMessageItems( - [ - msg({ id: "m1", createdAt: "2026-01-01T10:00:00.000Z" }), - msg({ id: "m2", createdAt: "2026-01-01T10:01:00.000Z" }), - ], - "m2", - ) - expect(items.map((i) => i.kind)).toEqual(["date-divider", "message", "new-divider", "message"]) + it("merges date and New metadata in one actual message row", () => { + const items = flattenMessageItems([ + msg({ id: "m1", createdAt: "2026-01-01T12:00:00.000Z" }), + msg({ id: "m2", createdAt: "2026-01-02T12:00:00.000Z" }), + ], "m2") + expect(items).toHaveLength(2) + expect(items[1]).toMatchObject({ newDivider: true, dateLabel: expect.any(String) }) + expect(items[0].newDivider).toBeUndefined() }) - it("emits a same-day new-divider with no dateLabel (no merge)", () => { - const items = flattenMessageItems( - [ - msg({ id: "m1", createdAt: "2026-01-01T10:00:00.000Z" }), - msg({ id: "m2", createdAt: "2026-01-01T10:01:00.000Z" }), - ], - "m2", - ) - const newDivider = items.find((i) => i.kind === "new-divider")! - expect(newDivider.dateLabel).toBeUndefined() + it("does not invent a New row for an absent anchor", () => { + expect(flattenMessageItems([msg({ id: "m1" })], "absent").some(item => item.newDivider)).toBe(false) }) - it("merges into ONE new-divider carrying a dateLabel when the unread anchor is the first message of a new day", () => { - const items = flattenMessageItems( - [ - msg({ id: "m1", createdAt: "2026-01-01T12:00:00.000Z" }), - msg({ id: "m2", createdAt: "2026-01-02T12:00:00.000Z" }), - ], - "m2", - ) - // No separate date-divider precedes the merged row — just message, then - // the single new-divider carrying the day's label, then the message. - expect(items.map((i) => i.kind)).toEqual(["date-divider", "message", "new-divider", "message"]) - const dividers = items.filter((i) => i.kind === "date-divider") - expect(dividers).toHaveLength(1) - const newDivider = items.find((i) => i.kind === "new-divider")! - expect(typeof newDivider.dateLabel).toBe("string") - expect(newDivider.dateLabel!.length).toBeGreaterThan(0) - }) - - it("emits a plain date-divider (no new-divider) when there is no unread anchor", () => { - const items = flattenMessageItems( - [ - msg({ id: "m1", createdAt: "2026-01-01T12:00:00.000Z" }), - msg({ id: "m2", createdAt: "2026-01-02T12:00:00.000Z" }), - ], - undefined, - ) - expect(items.some((i) => i.kind === "new-divider")).toBe(false) - expect(items.filter((i) => i.kind === "date-divider")).toHaveLength(2) + it("keeps the old first message key when a same-day prepend removes its date prefix", () => { + const anchor = msg({ id: "anchor", createdAt: "2026-01-01T12:01:00.000Z" }) + const original = flattenMessageItems([anchor], "anchor")[0] + const next = flattenMessageItems([msg({ id: "older", createdAt: "2026-01-01T12:00:00.000Z" }), anchor], undefined)[1] + expect(next.key).toBe(original.key) + expect(next.dateLabel).toBeUndefined() + expect(next.newDivider).toBeUndefined() + expect(next.m.grouped).toBe(true) }) it("marks a message 'grouped' when it's a same-author chat reply within the grouping window on the same day", () => { @@ -252,41 +221,11 @@ describe("flattenMessageItems", () => { }) describe("estimateRowHeight", () => { - it("returns a fixed constant for a date-divider row", () => { - const items = flattenMessageItems([msg({ id: "m1" })], undefined) - const divider = items.find((i) => i.kind === "date-divider")! - expect(estimateRowHeight(divider)).toBeGreaterThan(0) - expect(estimateRowHeight(divider)).toBe(estimateRowHeight(divider)) - }) - - it("returns a fixed constant for a new-divider row, distinct in value from an empty message row (dividers are shorter)", () => { - const items = flattenMessageItems( - [msg({ id: "m1" }), msg({ id: "m2" })], - "m2", - ) - const newDivider = items.find((i) => i.kind === "new-divider")! - const message = items.find((i) => i.kind === "message")! - expect(estimateRowHeight(newDivider)).toBeLessThan(estimateRowHeight(message)) - }) - - it("estimates a merged new-divider (with dateLabel) taller than a bare one, matching the date-divider height", () => { - const merged = flattenMessageItems( - [ - msg({ id: "m1", createdAt: "2026-01-01T12:00:00.000Z" }), - msg({ id: "m2", createdAt: "2026-01-02T12:00:00.000Z" }), - ], - "m2", - ).find((i) => i.kind === "new-divider")! - const bare = flattenMessageItems( - [ - msg({ id: "m1", createdAt: "2026-01-01T10:00:00.000Z" }), - msg({ id: "m2", createdAt: "2026-01-01T10:01:00.000Z" }), - ], - "m2", - ).find((i) => i.kind === "new-divider")! - const dateDivider = flattenMessageItems([msg({ id: "m1" })], undefined).find((i) => i.kind === "date-divider")! - expect(estimateRowHeight(merged)).toBeGreaterThan(estimateRowHeight(bare)) - expect(estimateRowHeight(merged)).toBe(estimateRowHeight(dateDivider)) + it("estimates date and New prefixes once within the message", () => { + const body = flattenMessageItems([msg({ id: "m1", createdAt: undefined })], undefined)[0] + expect(estimateRowHeight({ ...body, dateLabel: "Today" }) - estimateRowHeight(body)).toBe(32) + expect(estimateRowHeight({ ...body, newDivider: true }) - estimateRowHeight(body)).toBe(24) + expect(estimateRowHeight({ ...body, dateLabel: "Today", newDivider: true }) - estimateRowHeight(body)).toBe(32) }) it("scales up with longer text content", () => { @@ -358,7 +297,7 @@ describe("computeBelowCount", () => { it("counts only message rows strictly after the last visible index", () => { // lastVisibleIndex 1 (first message) → m2, m3 remain below = 2 - expect(computeBelowCount(items, 1)).toBe(2) + expect(computeBelowCount(items, 0)).toBe(2) }) it("excludes divider rows below the fold from the count", () => { @@ -372,8 +311,8 @@ describe("computeBelowCount", () => { ], undefined, ) - expect(twoDay.map((i) => i.kind)).toEqual(["date-divider", "message", "date-divider", "message"]) - expect(computeBelowCount(twoDay, 1)).toBe(1) + expect(twoDay.map((i) => i.kind)).toEqual(["message", "message"]) + expect(computeBelowCount(twoDay, 0)).toBe(1) }) it("returns 0 for an empty list", () => { diff --git a/src/web/src/lib/community/message-list-items.ts b/src/web/src/lib/community/message-list-items.ts index 673e6f4e1..2c67a491f 100644 --- a/src/web/src/lib/community/message-list-items.ts +++ b/src/web/src/lib/community/message-list-items.ts @@ -7,45 +7,16 @@ function messageDisplayKey(message: Msg): string { return `msg:client:${JSON.stringify([message.authorId, nonce])}` } -// Pure list-prep logic for the virtualized `MessageList` — split out of -// `message-list.tsx` (a "use client" component) into its own module with no -// React/JSX so `use-scroll-anchor.ts` can import `FlatItem`/`estimateRowHeight` -// /`computeBelowCount` without creating a `message-list.tsx` ↔ -// `use-scroll-anchor.ts` circular import (message-list.tsx also imports -// `useScrollAnchor` from that file). - -// Consecutive messages from the same author cluster (avatar+name suppressed -// on the second+ row) when they land within this window of each other. const MESSAGE_GROUP_WINDOW_MS = 7 * 60 * 1000 -// One virtual row per divider or per message — flattened rather than -// nested into cluster arrays (the pre-virtualization shape) so -// `@tanstack/react-virtual` can measure/position each row independently. -// `grouped` (avatar/name suppressed) is still computed against the -// immediately preceding MESSAGE item, same rule the old `clusters` memo -// used — just not re-nested into a cluster array afterward. -export type FlatItem = - | { kind: "date-divider"; label: string; key: string } - | { kind: "new-divider"; key: string; dateLabel?: string } - | { kind: "message"; m: RenderMsg; key: string } +export type FlatItem = { + kind: "message" + m: RenderMsg + key: string + dateLabel?: string + newDivider?: boolean +} -// Flatten a message array into one row per divider/message. Exported for -// direct unit testing (see message-list.test.ts) — mirrors `member-list.tsx`'s -// `flattenGroups`/`computeDuplicateNames` pattern of exporting pure list-prep -// logic out of the component for testability without rendering. -// `hasMoreOlder` — whether an older page exists beyond this window's top (the -// reverse-infinite top sentinel is live). When true, the window's FIRST message -// has an unknowable true predecessor: its real grouping can't be computed until -// the older page loads. Defaulting it to ungrouped (avatar shown) then -// re-grouping on prepend produces a pop-OUT flash (avatar appears, then -// vanishes when the older same-author message arrives) — read by the eye as -// "something broke". Instead, default the window-first message to GROUPED (no -// avatar) while older content is still pending: the common mid-history reload -// has that top message continuing an older same-author run (so grouped is -// usually already correct), and when it is genuinely a new group the avatar -// fades IN on prepend — a gentler, expected "content arrived" motion. Once the -// top is truly reached (`hasMoreOlder` false), the first message groups by the -// real rule (nothing above it → ungrouped → its avatar shows, correctly). export function flattenMessageItems( messages: Msg[], newDividerBefore: string | undefined, @@ -59,35 +30,16 @@ export function flattenMessageItems( const curDate = dateKey(m.createdAt) const showDateDivider = !!(curDate && curDate !== prevDate) const isNewDivider = m.id === newDividerBefore - if (showDateDivider && isNewDivider) { - items.push({ kind: "new-divider", key: "new-divider", dateLabel: formatDateLabel(m.createdAt!) }) - } else { - if (showDateDivider) { - // Keyed by the DATE (not the first-of-day message id) so an older-page - // prepend that pushes new same-day messages in front of the current - // first-of-day row keeps the divider's key stable. Without this, - // `@tanstack/react-virtual`'s anchor path (see use-scroll-anchor.ts) — - // which captures the anchor row's key before prepend and finds it in - // the new items after — falls off when the anchor sits on `items[0]` - // (the top-of-window divider), the divider gets re-emitted with a - // different `date:${id}` key, and `anchorTo: "end"` silently no-ops. - // Symptom: viewport jumps to the top of the loaded window on every - // top-sentinel load. `curDate` is `YYYY-MM-DD`, one row per day, so - // it's a stable identity. - items.push({ kind: "date-divider", label: formatDateLabel(m.createdAt!), key: `date:${curDate}` }) - } - if (isNewDivider) { - items.push({ kind: "new-divider", key: "new-divider" }) - } - } - // Window-first message with an older page still pending: force grouped - // (suppress its avatar) rather than guess ungrouped — see the doc comment - // above. `m.type === "chat"` keeps a system message (which never groups) - // out of this special-case. Chat first-messages only. const isPendingWindowFirst = !seenFirstMessage && hasMoreOlder && m.type === "chat" const grouped = isPendingWindowFirst || !!(prev && m.type === "chat" && !m.replyTo && !showDateDivider && prev.authorName === m.authorName && prev.createdAt && m.createdAt && (new Date(m.createdAt).getTime() - new Date(prev.createdAt).getTime()) < MESSAGE_GROUP_WINDOW_MS) - items.push({ kind: "message", m: { ...m, grouped }, key: messageDisplayKey(m) }) + items.push({ + kind: "message", + m: { ...m, grouped }, + key: messageDisplayKey(m), + ...(showDateDivider ? { dateLabel: formatDateLabel(m.createdAt!) } : {}), + ...(isNewDivider ? { newDivider: true } : {}), + }) seenFirstMessage = true prev = m } @@ -135,10 +87,10 @@ function estimateAttachmentsHeight(m: Msg): number { // Exported for direct unit testing (see message-list.test.ts). export function estimateRowHeight(item: FlatItem): number { - if (item.kind === "date-divider") return DATE_DIVIDER_ESTIMATE_PX - if (item.kind === "new-divider") return item.dateLabel ? DATE_DIVIDER_ESTIMATE_PX : NEW_DIVIDER_ESTIMATE_PX const m = item.m let height = MESSAGE_BASE_ESTIMATE_PX + estimateTextHeight(m.content) + estimateAttachmentsHeight(m) + if (item.dateLabel) height += DATE_DIVIDER_ESTIMATE_PX + else if (item.newDivider) height += NEW_DIVIDER_ESTIMATE_PX if (m.replyTo) height += REPLY_HEADER_ESTIMATE_PX if (m.embeds?.length) height += EMBED_ESTIMATE_PX * m.embeds.length if (m.reactions?.length) height += REACTIONS_ESTIMATE_PX @@ -146,18 +98,6 @@ export function estimateRowHeight(item: FlatItem): number { return height } -// ── "↓ N below" pill count ─────────────────────────────────────────────── -// Replaces the pre-virtualization `recomputeBelow`'s DOM-row-walk -// (`querySelectorAll("[data-msg-id]")` + `offsetTop` comparison) with a -// plain arithmetic derivation from data `getVirtualItems()` already -// exposes on every render. Counts only MESSAGE rows below the last visible -// index — date/NEW dividers are structural, not messages, so a bare -// `itemCount - 1 - lastVisibleIndex` would inflate the badge by one per -// divider below the fold. Exported for direct unit testing. export function computeBelowCount(items: FlatItem[], lastVisibleIndex: number): number { - let count = 0 - for (let i = lastVisibleIndex + 1; i < items.length; i++) { - if (items[i].kind === "message") count++ - } - return count + return Math.max(0, items.length - Math.max(0, lastVisibleIndex + 1)) } diff --git a/src/web/src/test/e2e-ui/18-new-divider-scroll.spec.ts b/src/web/src/test/e2e-ui/18-new-divider-scroll.spec.ts index 276350ca7..38752b1dc 100644 --- a/src/web/src/test/e2e-ui/18-new-divider-scroll.spec.ts +++ b/src/web/src/test/e2e-ui/18-new-divider-scroll.spec.ts @@ -3,6 +3,11 @@ import { tid } from "./_fixtures/testids" import { seedChannel, seedJoinServer, seedMessage, seedServer } from "./_fixtures/seed" import { WEB_URL } from "./_setup/paths" +type BodySample = { + scrollTop: number + bodies: Array<{ id: string; top: number; bottom: number; height: number }> +} + test("NEW divider does not fight upward scrolling while a peer message arrives", async ({ asUser }) => { test.setTimeout(120_000) const serverId = await seedServer("alice", `NEW scroll ${Date.now()}`) @@ -50,36 +55,75 @@ test("NEW divider does not fight upward scrolling while a peer message arrives", expect(box).not.toBeNull() await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2) - await scroller.evaluate((element) => { - const samples: number[] = [element.scrollTop] - Object.defineProperty(window, "__newDividerScrollEvents", { - configurable: true, - value: samples, - }) - element.addEventListener("scroll", () => samples.push(element.scrollTop), { passive: true }) + const recorder = await scroller.evaluateHandle((element) => { + const root = element as HTMLElement + const samples: BodySample[] = [] + const sample = () => { + const viewport = root.getBoundingClientRect() + const bodies = Array.from(root.querySelectorAll("[data-msg-id]")) + .flatMap((body) => { + const rect = body.getBoundingClientRect() + const id = body.dataset.msgId + return id && rect.height > 0 && rect.width > 0 + && rect.bottom > viewport.top + 1 && rect.top < viewport.bottom - 1 + ? [{ id, top: rect.top - viewport.top, bottom: rect.bottom - viewport.top, height: rect.height }] + : [] + }) + samples.push({ scrollTop: root.scrollTop, bodies }) + } + let frame: number + const onFrame = () => { + sample() + frame = requestAnimationFrame(onFrame) + } + sample() + root.addEventListener("scroll", sample, { passive: true }) + frame = requestAnimationFrame(onFrame) + return { + sample, + finish: () => { + cancelAnimationFrame(frame) + root.removeEventListener("scroll", sample) + sample() + return samples + }, + } }) - const samples: number[] = [await scroller.evaluate((element) => element.scrollTop)] - const liveAppend = (async () => { - await page.waitForTimeout(250) // inject the append during the upward-scroll gesture - return seedMessage("bob", channelId, `live during upward scroll ${Date.now()}`) - })() - for (let index = 0; index < 60; index++) { - await page.mouse.wheel(0, -24) - await page.waitForTimeout(24) // fixed wheel-event cadence under test - samples.push(await scroller.evaluate((element) => element.scrollTop)) + let bodySamples: BodySample[] = [] + try { + const liveAppend = (async () => { + await page.waitForTimeout(250) // inject the append during the upward-scroll gesture + return seedMessage("bob", channelId, `live during upward scroll ${Date.now()}`) + })() + for (let index = 0; index < 60; index++) { + await page.mouse.wheel(0, -24) + await page.waitForTimeout(24) // fixed wheel-event cadence under test + await recorder.evaluate((recording) => recording.sample()) + } + await liveAppend + await page.waitForTimeout(250) // post-append scroll-correction exclusion window + } finally { + bodySamples = await recorder.evaluate((recording) => recording.finish()) + await recorder.dispose() } - await liveAppend - await page.waitForTimeout(250) // post-append scroll-correction exclusion window - samples.push(await scroller.evaluate((element) => element.scrollTop)) - const scrollEvents = await page.evaluate(() => ( - window as unknown as { __newDividerScrollEvents: number[] } - ).__newDividerScrollEvents) - const start = samples[0]! - const end = samples.at(-1)! - expect(end).toBeLessThan(start - 600) - for (let index = 1; index < scrollEvents.length; index++) { - expect(scrollEvents[index]!).toBeLessThanOrEqual(scrollEvents[index - 1]! + 4) + expect(bodySamples.length).toBeGreaterThan(1) + let upwardProgress = 0 + for (let index = 1; index < bodySamples.length; index++) { + const before = bodySamples[index - 1]! + const after = bodySamples[index]! + const common = before.bodies.flatMap((body) => { + const next = after.bodies.find((candidate) => candidate.id === body.id) + return next ? [{ id: body.id, delta: next.top - body.top }] : [] + }) + const diagnostic = JSON.stringify({ index, before, after, common }) + expect(common.length, diagnostic).toBeGreaterThan(0) + for (const body of common) { + expect(body.delta, diagnostic).toBeGreaterThanOrEqual(-4) + } + const deltas = common.map((body) => body.delta).sort((a, b) => a - b) + upwardProgress += deltas[Math.floor(deltas.length / 2)]! } + expect(upwardProgress).toBeGreaterThan(600) }) diff --git a/src/web/src/test/e2e-ui/21-mobile-message-layout.spec.ts b/src/web/src/test/e2e-ui/21-mobile-message-layout.spec.ts index d4323598e..627a9982d 100644 --- a/src/web/src/test/e2e-ui/21-mobile-message-layout.spec.ts +++ b/src/web/src/test/e2e-ui/21-mobile-message-layout.spec.ts @@ -7,7 +7,7 @@ type RowRect = { top: number; bottom: number; height: number } async function messageRowRect(page: Page, messageId: string): Promise { return page.locator(`[data-msg-id="${messageId}"]`).evaluate((message) => { - const row = message.parentElement + const row = message.closest("[data-index]") if (!row?.hasAttribute("data-index")) throw new Error("virtual message row not found") const rect = row.getBoundingClientRect() return { top: rect.top, bottom: rect.bottom, height: rect.height } @@ -29,7 +29,7 @@ async function waitForStableScrollerFrames(page: Page, messageId: string): Promi let observedFrames = 0 const sample = () => { const message = document.querySelector(`[data-msg-id="${CSS.escape(targetMessageId)}"]`) - const row = message?.parentElement + const row = message?.closest("[data-index]") if (!row) { rejectStable(new Error(`message row ${targetMessageId} disappeared`)) return diff --git a/src/web/src/test/e2e-ui/54-blog-multizone.spec.ts b/src/web/src/test/e2e-ui/54-blog-multizone.spec.ts index 51d31c587..5653f8220 100644 --- a/src/web/src/test/e2e-ui/54-blog-multizone.spec.ts +++ b/src/web/src/test/e2e-ui/54-blog-multizone.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from "@playwright/test" +import { tid } from "./_fixtures/testids" test("main and Blog navigate across Workers through one public origin", async ({ page }) => { const documentRequests: string[] = [] @@ -9,6 +10,10 @@ test("main and Blog navigate across Workers through one public origin", async ({ }) await page.goto("/") + const banner = page.getByTestId(tid.analyticsConsentBanner) + await expect(banner).toBeVisible() + await banner.getByRole("button", { name: "Only necessary", exact: true }).click() + await expect(banner).toHaveCount(0) const publicOrigin = new URL(page.url()).origin await page.locator(".hero-section").evaluate((hero) => { window.scrollTo(0, hero.getBoundingClientRect().bottom + window.scrollY + 1) diff --git a/src/web/src/test/message-scroll-fixture.ts b/src/web/src/test/message-scroll-fixture.ts new file mode 100644 index 000000000..486d5f089 --- /dev/null +++ b/src/web/src/test/message-scroll-fixture.ts @@ -0,0 +1,160 @@ +import { createElement, StrictMode, useLayoutEffect } from "react" +import { vi } from "vitest" +import { act, render, fireEvent } from "@/test/react-dom-harness" +import { VirtualRows } from "@/components/community/messages/virtual-cursor-list" +import type { FlatItem } from "@/lib/community/message-list-items" +import { useScrollAnchor } from "@/hooks/community/use-scroll-anchor" + +export const message = (id: string, authorId = "peer"): FlatItem => ({ kind: "message", key: `msg:${id}`, m: { id, type: "chat", authorId, grouped: false } }) +type Input = Parameters[0] +type Result = ReturnType +let latest: Result +let height: number +let firstPrefix: number +let bodyHeights: Map +let frames: Map +let frameId: number +let resizeObservers: Array<{ callback: ResizeObserverCallback; elements: Set }> +let scrollCalls: number[] +let scrollDescriptor: PropertyDescriptor | undefined + +function prefix(item: FlatItem, index: number) { + return (index === 0 ? firstPrefix : 0) + (item.dateLabel ? 32 : item.newDivider ? 24 : 0) +} +const ROOT_SELECTOR = '[data-testid="scroll"], [data-testid="community-message-scroller"]' +function isScrollRoot(node: HTMLElement) { return node.matches(ROOT_SELECTOR) } +function rootFor(node: HTMLElement) { return node.closest(ROOT_SELECTOR) } +function rowGeometry(node: HTMLElement) { + const wrapper = node.matches('[data-index]') ? node : node.closest('[data-index]') + const root = rootFor(node) + const content = wrapper?.querySelector('[data-msg-id]') + const size = content ? bodyHeights.get(content.dataset.msgId!) ?? 100 : 0 + const actualRow = wrapper?.querySelector("[data-message-row-key]") + const before = content?.dataset.prefix !== undefined ? Number(content.dataset.prefix) + : (wrapper?.dataset.index === "0" ? firstPrefix : 0) + + (actualRow?.querySelector("[data-new-divider]") ? 24 + : actualRow?.querySelector("span.text-xs") ? 32 : 0) + const translation = wrapper?.style.transform.match(/translate3d\(0,\s*(-?[\d.]+)px/) + const start = Number.parseFloat(translation?.[1] ?? wrapper?.style.top ?? "0") || 0 + const y = start - (root?.scrollTop ?? 0) + if (node.matches('[data-msg-id]')) return DOMRect.fromRect({ y: y + before, width: 320, height: size }) + if (node.matches('[data-new-divider]')) return DOMRect.fromRect({ y: y + before - 24, width: 320, height: 24 }) + return DOMRect.fromRect({ y, width: 320, height: size + before }) +} +function Probe({ input, onLayout }: { input: Input; onLayout?: (result: Result) => void }) { + const result = useScrollAnchor(input) + useLayoutEffect(() => { latest = result; onLayout?.(result) }) + return createElement("div", { "data-slot": "community-conversation-surface" }, + createElement("div", { ref: result.scrollRef, "data-testid": "scroll" }, + createElement("div", { "data-message-list-content": "", "data-read-position-ready": String(result.readPositionReady) }, + createElement(VirtualRows, { + items: input.items, virtualizer: result.virtualizer, itemKey: (item) => item.key, + renderItem: (item, index) => createElement("div", null, + item.newDivider ? createElement("div", { "data-new-divider": "" }) : null, + createElement("div", { "data-msg-id": item.m.id, "data-prefix": prefix(item, index) }), + ), + }), + ), + ), + createElement("div", { "data-slot": "community-conversation-footer" }), + ) +} +export function runFrames(count = 26) { + for (let i = 0; i < count; i++) { + act(() => { + vi.advanceTimersByTime(16) + for (const root of document.querySelectorAll(ROOT_SELECTOR)) { + const clamped = Math.max(0, Math.min(root.scrollTop, root.scrollHeight - root.clientHeight)) + if (root.scrollTop !== clamped) { + root.scrollTop = clamped + root.dispatchEvent(new Event("scroll")) + } + } + const pending = [...frames] + frames.clear() + for (const [, callback] of pending) callback(performance.now()) + }) + } +} +export function resize(frameCount = 26) { + act(() => { + for (const observer of [...resizeObservers]) { + const entries = [...observer.elements].filter(element => element.isConnected).map(target => ({ + target, borderBoxSize: [{ blockSize: (target as HTMLElement).offsetHeight, inlineSize: 320 }], + } as unknown as ResizeObserverEntry)) + if (entries.length) observer.callback(entries, {} as ResizeObserver) + } + }) + runFrames(frameCount) +} +export function mount(overrides: Partial = {}, strict = false, onLayout?: (result: Result) => void) { + const input: Input = { items: Array.from({ length: 14 }, (_, i) => message(`m${i}`)), initialScrollReady: true, hasMoreOlder: true, ...overrides } + const element = () => strict ? createElement(StrictMode, null, createElement(Probe, { input, onLayout })) : createElement(Probe, { input, onLayout }) + const view = render(element()) + const root = view.getByTestId("scroll") as HTMLElement + resize() + const stage = (next: Partial) => { Object.assign(input, next); view.rerender(element()) } + const update = (next: Partial) => { stage(next); resize() } + const move = (offset: number) => { + fireEvent.wheel(root, { deltaY: offset < root.scrollTop ? -20 : 20 }) + act(() => root.scrollTo({ top: offset })) + runFrames() + } + return { view, root, input, stage, update, move } +} +export function bodyTop(root: HTMLElement, id: string) { return root.querySelector(`[data-msg-id="${id}"]`)!.getBoundingClientRect().top } + +export function installMessageScrollFixture() { + height = 500 + firstPrefix = 88 + bodyHeights = new Map() + frames = new Map() + frameId = 0 + resizeObservers = [] + scrollCalls = [] + vi.useFakeTimers() + vi.spyOn(window, "requestAnimationFrame").mockImplementation(callback => { frames.set(++frameId, callback); return frameId }) + vi.spyOn(window, "cancelAnimationFrame").mockImplementation(id => { frames.delete(id) }) + vi.stubGlobal("ResizeObserver", class { + private record: typeof resizeObservers[number] + constructor(callback: ResizeObserverCallback) { this.record = { callback, elements: new Set() }; resizeObservers.push(this.record) } + observe(element: Element) { this.record.elements.add(element) } + unobserve(element: Element) { this.record.elements.delete(element) } + disconnect() { this.record.elements.clear() } + }) + vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockImplementation(function (this: HTMLElement) { return isScrollRoot(this) ? height : rowGeometry(this).height }) + vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockImplementation(function (this: HTMLElement) { return this.clientHeight }) + vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockReturnValue(320) + vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockImplementation(function (this: HTMLElement) { + return isScrollRoot(this) + ? Math.max(height, Number.parseFloat(this.querySelector('[data-message-list-content] > div')?.style.height ?? "0") || 0) + : rowGeometry(this).height + }) + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (this: HTMLElement) { + return isScrollRoot(this) ? DOMRect.fromRect({ width: 320, height }) : rowGeometry(this) + }) + scrollDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollTo") + Object.defineProperty(HTMLElement.prototype, "scrollTo", { configurable: true, value(this: HTMLElement, options: ScrollToOptions) { + this.scrollTop = Math.max(0, Math.min(options.top ?? this.scrollTop, this.scrollHeight - this.clientHeight)) + scrollCalls.push(this.scrollTop) + this.dispatchEvent(new Event("scroll")) + } }) +} +export function restoreMessageScrollFixture() { + vi.restoreAllMocks() + vi.unstubAllGlobals() + vi.useRealTimers() + if (scrollDescriptor) Object.defineProperty(HTMLElement.prototype, "scrollTo", scrollDescriptor) + else Reflect.deleteProperty(HTMLElement.prototype, "scrollTo") +} + +export const scrollFixture = { + get latest() { return latest }, + get height() { return height }, + set height(value: number) { height = value }, + get firstPrefix() { return firstPrefix }, + set firstPrefix(value: number) { firstPrefix = value }, + get bodyHeights() { return bodyHeights }, + get frames() { return frames }, + get scrollCalls() { return scrollCalls }, +}