From 8557b9f158ec25007ed6eefeabb102338aaa6a1e Mon Sep 17 00:00:00 2001 From: DIYgod Date: Thu, 21 May 2026 14:31:56 +0800 Subject: [PATCH] fix(desktop): scroll timeline to top before refresh --- .../context/EntriesContext.test.tsx | 111 ++++++++++++++++++ .../entry-column/context/EntriesContext.tsx | 5 +- .../src/modules/entry-column/index.tsx | 28 ++++- .../entry-column/layouts/EntryListHeader.tsx | 6 +- 4 files changed, 141 insertions(+), 9 deletions(-) create mode 100644 apps/desktop/layer/renderer/src/modules/entry-column/context/EntriesContext.test.tsx diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/context/EntriesContext.test.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/context/EntriesContext.test.tsx new file mode 100644 index 000000000..76d496f8b --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-column/context/EntriesContext.test.tsx @@ -0,0 +1,111 @@ +import * as React from "react" +import { act } from "react" +import type { Root } from "react-dom/client" +import { createRoot } from "react-dom/client" +import { afterEach, beforeAll, describe, expect, test, vi } from "vitest" + +import type { useEntriesByView as useEntriesByViewType } from "../hooks/useEntriesByView" +import { EntriesProvider, useEntriesActions } from "./EntriesContext" + +const mocks = vi.hoisted(() => ({ + useEntriesByView: vi.fn(), + routeParams: vi.fn(() => ({ view: 0 })), +})) + +vi.mock("../hooks/useEntriesByView", () => ({ + useEntriesByView: mocks.useEntriesByView, +})) + +vi.mock("~/hooks/biz/useRouteParams", () => ({ + useRouteParams: mocks.routeParams, +})) + +const useEntriesByViewMock = mocks.useEntriesByView as unknown as { + mockReturnValue: (value: ReturnType) => void +} + +describe("EntriesProvider", () => { + let root: Root | null = null + let container: HTMLElement | null = null + + beforeAll(() => { + ;(globalThis as typeof globalThis & { React: typeof React }).React = React + ;( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(async () => { + if (root) { + await act(async () => { + root?.unmount() + }) + } + + container?.remove() + root = null + container = null + vi.restoreAllMocks() + }) + + test("runs the registered reset callback before refetching entries", async () => { + const events: string[] = [] + const refetch = vi.fn(async () => { + events.push("refetch") + }) + + useEntriesByViewMock.mockReturnValue({ + type: "remote", + entriesIds: [], + groupedCounts: undefined, + hasNextPage: false, + isFetchingNextPage: false, + isFetching: false, + isLoading: false, + error: null, + refetch, + fetchNextPage: vi.fn(), + hasNext: false, + isRefetching: false, + isReady: true, + }) + + let refetchEntries: (() => void | Promise) | undefined + const reset = vi.fn(() => { + events.push("reset") + }) + + const Consumer = () => { + const actions = useEntriesActions() + + React.useEffect(() => { + actions.setOnReset(reset) + refetchEntries = actions.refetch + + return () => { + actions.setOnReset(null) + } + }, [actions]) + + return null + } + + container = document.createElement("div") + document.body.append(container) + root = createRoot(container) + + await act(async () => { + root?.render( + + + , + ) + }) + + await act(async () => { + await refetchEntries?.() + }) + + expect(events).toEqual(["reset", "refetch"]) + }) +}) diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/context/EntriesContext.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/context/EntriesContext.tsx index 3433b5ed3..5d26a0ccd 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/context/EntriesContext.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/context/EntriesContext.tsx @@ -78,7 +78,10 @@ export const EntriesProvider: React.FC = ({ children }) // Stable actions that reference latest refs const fetchNextPageStable = useCallback(() => fetchNextPageRef.current?.(), []) - const refetchStable = useCallback(() => refetchRef.current?.(), []) + const refetchStable = useCallback(() => { + onResetRef.current?.() + return refetchRef.current?.() + }, []) const setOnResetStable = useCallback((cb: (() => void) | null) => { onResetRef.current = cb }, []) diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx index 73dac23a8..137aea92f 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx @@ -37,17 +37,25 @@ import { EntryRootStateContext } from "./store/EntryColumnContext" function EntryColumnContent() { const listRef = useRef>(undefined) + const scrollAreaRef = useRef(null) const { t } = useTranslation() const state = useEntriesState() const actions = useEntriesActions() + const scrollTimelineToTop = useCallback(() => { + listRef.current?.scrollToOffset(0) + + const scrollArea = scrollAreaRef.current + if (!scrollArea) return + + scrollArea.scrollTop = 0 + scrollArea.scrollLeft = 0 + }, []) // Register reset handler to keep scroll behavior when data resets useEffect(() => { - actions.setOnReset(() => { - listRef.current?.scrollToIndex(0) - }) + actions.setOnReset(scrollTimelineToTop) return () => actions.setOnReset(null) - }, [actions]) + }, [actions, scrollTimelineToTop]) const { entriesIds, groupedCounts } = state useSnapEntryIdList(entriesIds) @@ -169,9 +177,17 @@ function EntryColumnContent() { !state.error && (!feed || feed?.type === "feed") && } - + - + {entriesIds.length === 0 ? ( state.isLoading ? ( diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/layouts/EntryListHeader.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/layouts/EntryListHeader.tsx index 33e4b4ace..50faa04d1 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/layouts/EntryListHeader.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/layouts/EntryListHeader.tsx @@ -43,7 +43,8 @@ import { SwitchToMasonryButton } from "./buttons/SwitchToMasonryButton" export const EntryListHeader: FC<{ refetch: () => void isRefreshing: boolean -}> = ({ refetch, isRefreshing }) => { + onBeforeRefresh?: () => void +}> = ({ refetch, isRefreshing, onBeforeRefresh }) => { const routerParams = useRouteParams() const { t } = useTranslation() @@ -188,7 +189,8 @@ export const EntryListHeader: FC<{ { - refreshFeed() + onBeforeRefresh?.() + void refreshFeed() }} >